| 1 | //! What the runner is doing, and when it is not running or is failing, why |
| 2 | //! and how to fix it: the one catalogue of those words. The tray shows a |
| 3 | //! diagnosis's title, the settings window all of it with its buttons, and |
| 4 | //! the site shows it as the app sends it, word for word, on its Runners |
| 5 | //! page (and keeps it, so a person's other computers are listed with |
| 6 | //! theirs). The site adds nothing of its own: what it says of a computer |
| 7 | //! whose g1t is closed is `closed` here, sent with every report. |
| 8 | //! |
| 9 | //! Each action is an id the settings window and the site turn into a |
| 10 | //! button; bridge.rs and settings.js do what it says. |
| 11 | //! |
| 12 | //! The sandbox (launcher.rs) says why something is wrong in its own |
| 13 | //! sentences. Those this catalogue knows (WSL not installed, virtualization |
| 14 | //! off, Windows Sandbox off, a release without the sandbox's images, …) |
| 15 | //! are said in its words, with a fix and its buttons; any other is shown as |
| 16 | //! the launcher wrote it. With the sandbox, each kind of job it runs (Linux |
| 17 | //! jobs, Windows jobs) is a card of its own (`capabilities`), with its own |
| 18 | //! state, cause, fix and Start or Stop, since one can run while the other |
| 19 | //! cannot, or is stopped. The whole runner's diagnosis then only says what |
| 20 | //! the cards don't (`in_capabilities` is false): signed out, no workspace, |
| 21 | //! not allowed, no launcher, the whole sandbox down. |
| 22 | |
| 23 | use serde::Serialize; |
| 24 | |
| 25 | use super::launcher::{self, State, Status, Why}; |
| 26 | |
| 27 | /// Every state the runner can be in. |
| 28 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] |
| 29 | #[serde(rename_all = "snake_case")] |
| 30 | pub enum Code { |
| 31 | /// Running jobs is turned off in settings. |
| 32 | #[default] |
| 33 | Off, |
| 34 | /// The person pressed Stop. |
| 35 | Stopped, |
| 36 | /// Sandboxed, the person stopped every kind of job there is here. |
| 37 | KindsStopped, |
| 38 | Starting, |
| 39 | /// Waiting for the site to make a registration token. |
| 40 | Registering, |
| 41 | /// Nobody is signed in to g1t in the app. |
| 42 | SignedOut, |
| 43 | /// Signed in, owning no workspace: nowhere to register. |
| 44 | NoWorkspace, |
| 45 | /// The person may not add runners to the workspace. |
| 46 | NotAllowed, |
| 47 | RegisterFailed, |
| 48 | Idle, |
| 49 | Busy, |
| 50 | NoDocker, |
| 51 | /// The `g1t-runner` here is from before its sandbox launcher. |
| 52 | NoLauncher, |
| 53 | /// Windows, without WSL, which holds the sandbox's Linux VM. |
| 54 | NoWsl, |
| 55 | /// WSL set to mirrored networking, which reaches this computer's localhost. |
| 56 | WslMirrored, |
| 57 | /// The computer's virtualization is turned off. |
| 58 | NoVirtualization, |
| 59 | /// macOS, without vfkit, which runs the sandbox's VM. |
| 60 | NoVfkit, |
| 61 | /// Linux, with neither Sysbox in Docker nor KVM. |
| 62 | NoSysbox, |
| 63 | /// This computer lacks something the sandbox needs, by name (the |
| 64 | /// launcher's `needs`): each kind of job's card says what, and Set up |
| 65 | /// does what Windows can do itself. |
| 66 | NeedsSetup, |
| 67 | /// The newest release is published without the sandbox's images yet. |
| 68 | WaitingForImage, |
| 69 | /// The sandbox's image couldn't be downloaded, or isn't the release's. |
| 70 | ImageFailed, |
| 71 | /// The sandbox can't be made here, for a reason in the launcher's words. |
| 72 | SandboxUnavailable, |
| 73 | /// The sandbox failed, in the launcher's words. |
| 74 | SandboxFailed, |
| 75 | /// The computer has a sandbox the app didn't make, and has room for one. |
| 76 | SandboxTaken, |
| 77 | Updating, |
| 78 | UpdateFailed, |
| 79 | /// The newest release's manifest isn't signed with g1t's release key. |
| 80 | UpdateRefused, |
| 81 | Offline, |
| 82 | Crashed, |
| 83 | } |
| 84 | |
| 85 | /// How a state should look: a dot's colour. |
| 86 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] |
| 87 | #[serde(rename_all = "snake_case")] |
| 88 | pub enum Tone { |
| 89 | Ok, |
| 90 | Working, |
| 91 | Off, |
| 92 | Warning, |
| 93 | Problem, |
| 94 | } |
| 95 | |
| 96 | /// A one-click fix. |
| 97 | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] |
| 98 | pub struct Action { |
| 99 | pub id: &'static str, |
| 100 | pub label: &'static str, |
| 101 | /// Its button stays busy, after a click, until the runner's status |
| 102 | /// that follows changes (or an error, or a while with no change): the |
| 103 | /// runner starting, stopping, retrying or looking for updates. One |
| 104 | /// that opens a page, a window or copies is done once the app has it. |
| 105 | pub waits: bool, |
| 106 | /// What the person is asked before it goes ahead, for a fix that can't be undone. |
| 107 | #[serde(skip_serializing_if = "Option::is_none")] |
| 108 | pub confirm: Option<&'static str>, |
| 109 | } |
| 110 | |
| 111 | impl Action { |
| 112 | /// A fix that is done the moment the app has it. |
| 113 | const fn now(id: &'static str, label: &'static str) -> Self { |
| 114 | Self { id, label, waits: false, confirm: None } |
| 115 | } |
| 116 | |
| 117 | /// A fix whose effect the runner's status shows when it comes. |
| 118 | const fn waits(id: &'static str, label: &'static str) -> Self { |
| 119 | Self { id, label, waits: true, confirm: None } |
| 120 | } |
| 121 | |
| 122 | const fn confirming(self, question: &'static str) -> Self { |
| 123 | Self { confirm: Some(question), ..self } |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] |
| 128 | pub struct Diagnosis { |
| 129 | pub code: Code, |
| 130 | pub tone: Tone, |
| 131 | /// One line: the tray's, and the heading everywhere else. |
| 132 | pub title: String, |
| 133 | /// Why, in a sentence. |
| 134 | pub cause: Option<String>, |
| 135 | /// What to do about it. |
| 136 | pub fix: Option<String>, |
| 137 | pub actions: Vec<Action>, |
| 138 | /// With the sandbox: each kind of job it runs, as a card of its own. |
| 139 | #[serde(skip_serializing_if = "Vec::is_empty")] |
| 140 | pub capabilities: Vec<CapabilityLine>, |
| 141 | /// The cards say all of this, each for its kind of job: a page that |
| 142 | /// shows them shows no line of the whole runner's as well. |
| 143 | #[serde(skip_serializing_if = "is_false")] |
| 144 | pub in_capabilities: bool, |
| 145 | /// How the runner's updates stand: when g1t last looked, and a newer |
| 146 | /// one waiting. None for what is said once g1t is closed. |
| 147 | #[serde(skip_serializing_if = "Option::is_none")] |
| 148 | pub update: Option<UpdateLine>, |
| 149 | } |
| 150 | |
| 151 | fn is_false(value: &bool) -> bool { |
| 152 | !*value |
| 153 | } |
| 154 | |
| 155 | /// The runner's updates, as settings and the site's Desktops show them. |
| 156 | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] |
| 157 | pub struct UpdateLine { |
| 158 | /// When g1t last heard from the runner's releases, RFC 3339: the site, |
| 159 | /// which shows a report for minutes, says how long ago itself. |
| 160 | pub checked_at: Option<String>, |
| 161 | /// `Checked 12 min ago`, `Checking for updates…`. |
| 162 | pub checked: String, |
| 163 | pub checking: bool, |
| 164 | /// A newer version, downloaded and checked, waiting to be installed. |
| 165 | pub ready: Option<String>, |
| 166 | /// What becomes of it, for a runner with no kinds of job of its own |
| 167 | /// (Docker, or on this computer): `0.3.2 ready, installs after this |
| 168 | /// job`. A sandbox's cards each say it for their own kind. |
| 169 | pub note: Option<String>, |
| 170 | /// Looks now: "Check for runner updates". |
| 171 | pub check: Action, |
| 172 | } |
| 173 | |
| 174 | /// How one kind of job is, as its card's label says it. |
| 175 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] |
| 176 | #[serde(rename_all = "snake_case")] |
| 177 | pub enum KindState { |
| 178 | /// Running a job. |
| 179 | Busy, |
| 180 | /// Waiting for jobs. |
| 181 | Idle, |
| 182 | Starting, |
| 183 | /// Downloading the sandbox's image, which `progress` says how far. |
| 184 | Downloading, |
| 185 | Updating, |
| 186 | /// Waiting for something g1t sees to by itself, such as the release's images. |
| 187 | Waiting, |
| 188 | /// Stopped: by the person (`enabled` is false), or with the whole runner. |
| 189 | Stopped, |
| 190 | /// This computer can't run it, and `cause` says why. |
| 191 | Unavailable, |
| 192 | /// It should run and doesn't: `cause` and `fix`. |
| 193 | Problem, |
| 194 | } |
| 195 | |
| 196 | /// One kind of job the sandbox runs, `linux` or `windows` (or `macos`), |
| 197 | /// and how it is: Linux jobs can run while Windows jobs cannot. |
| 198 | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] |
| 199 | pub struct CapabilityLine { |
| 200 | pub id: String, |
| 201 | /// `Linux jobs`. |
| 202 | pub label: String, |
| 203 | pub tone: Tone, |
| 204 | pub state: KindState, |
| 205 | /// Its state in a few words, for its label: `Waiting for jobs`, `Not available`. |
| 206 | pub title: String, |
| 207 | /// What it is, in a line: `A Linux VM of its own, each job in a fresh container`. |
| 208 | pub about: String, |
| 209 | /// What is wrong, in a few words, over its cause: `WSL isn't installed`. |
| 210 | pub headline: Option<String>, |
| 211 | pub cause: Option<String>, |
| 212 | pub fix: Option<String>, |
| 213 | pub actions: Vec<Action>, |
| 214 | /// On in settings: the person hasn't stopped it. |
| 215 | pub enabled: bool, |
| 216 | /// This computer can run it. |
| 217 | pub available: bool, |
| 218 | /// It can be started and stopped on its own here (the launcher can). |
| 219 | pub can_toggle: bool, |
| 220 | /// What it is running now. |
| 221 | pub job: Option<JobLine>, |
| 222 | /// How far its image has downloaded, in percent. |
| 223 | pub progress: Option<u8>, |
| 224 | /// How it keeps jobs from the computer: `nested-container`, `vm`. |
| 225 | pub isolation: Option<String>, |
| 226 | /// Its runner's name on g1t: `build-box-linux`. |
| 227 | pub runner: Option<String>, |
| 228 | /// Its runner's version. |
| 229 | pub version: Option<String>, |
| 230 | /// A newer version waiting for it: `0.3.2 ready, installs after this job`. |
| 231 | pub update: Option<String>, |
| 232 | } |
| 233 | |
| 234 | /// The job a kind of job is running, with where to see it on g1t. |
| 235 | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] |
| 236 | pub struct JobLine { |
| 237 | /// `workflow` or `agent`. |
| 238 | pub kind: String, |
| 239 | pub name: String, |
| 240 | pub repo: String, |
| 241 | /// The repository's Actions page, where its run is. |
| 242 | pub link: Option<String>, |
| 243 | } |
| 244 | |
| 245 | /// What the words are filled in with. |
| 246 | #[derive(Clone, Debug, Default)] |
| 247 | pub struct Facts { |
| 248 | pub workspace: Option<String>, |
| 249 | /// How jobs run, as `sandboxed`, `in Docker`, `on this computer`. |
| 250 | pub backend: &'static str, |
| 251 | pub machine: String, |
| 252 | pub job: Option<String>, |
| 253 | pub detail: Option<String>, |
| 254 | pub exit_code: Option<i32>, |
| 255 | pub wait_seconds: Option<u64>, |
| 256 | /// The server's host: `g1t.sh`. |
| 257 | pub server: String, |
| 258 | /// The server's address, for links: `https://g1t.sh`. |
| 259 | pub site: String, |
| 260 | pub platform: String, |
| 261 | /// What the sandbox says to do, for a cause in its own words. |
| 262 | pub fix: Option<String>, |
| 263 | /// How the sandbox is, when jobs run sandboxed. |
| 264 | pub sandbox: Option<Status>, |
| 265 | /// The kinds of job the person stopped in settings. |
| 266 | pub off: Vec<String>, |
| 267 | /// The sandbox is up, or on its way: a kind of job that should run |
| 268 | /// and doesn't is failing, rather than stopped with everything else. |
| 269 | pub live: bool, |
| 270 | /// The kinds of job a launcher command is starting now. |
| 271 | pub starting: Vec<String>, |
| 272 | /// How far the sandbox's image has downloaded, while it does. |
| 273 | pub download: Option<launcher::Download>, |
| 274 | /// The person stopped the whole runner, or turned it off. |
| 275 | pub held: bool, |
| 276 | pub update: Update, |
| 277 | /// How Set up stands (setup.rs). |
| 278 | pub setup: SetUp, |
| 279 | /// What installs QEMU here: `sudo apt install qemu-system-x86`. |
| 280 | pub qemu_install: Option<String>, |
| 281 | } |
| 282 | |
| 283 | /// How Set up stands: what Windows can do itself for the sandbox, done |
| 284 | /// behind one administrator prompt (setup.rs). |
| 285 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 286 | pub struct SetUp { |
| 287 | /// Under way, and what it does: `turns on Virtual Machine Platform and installs WSL`. |
| 288 | pub running: Option<String>, |
| 289 | /// Why the last one didn't finish, until it is tried again. |
| 290 | pub failed: Option<String>, |
| 291 | /// The person chose Later over restarting now. |
| 292 | pub later: bool, |
| 293 | /// Every kind of job's needs, of those that are on: one press of Set |
| 294 | /// up meets them all, so each card says all it does. |
| 295 | pub needs: Vec<String>, |
| 296 | } |
| 297 | |
| 298 | /// How the runner's updates stand. |
| 299 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 300 | pub struct Update { |
| 301 | /// The g1t-runner here. |
| 302 | pub installed: Option<String>, |
| 303 | /// When g1t last heard from the releases, RFC 3339, and how long ago. |
| 304 | pub checked_at: Option<String>, |
| 305 | pub checked_ago: Option<u64>, |
| 306 | pub checking: bool, |
| 307 | /// A newer release, downloaded and checked, waiting to be put in place. |
| 308 | pub ready: Option<String>, |
| 309 | /// What is still to take it: `linux` (the sandbox's image), `windows` |
| 310 | /// (the program its runner is), or `runner` for a runner with no kinds |
| 311 | /// of job of its own. |
| 312 | pub waiting: Vec<String>, |
| 313 | /// Those taking it now. |
| 314 | pub installing: Vec<String>, |
| 315 | } |
| 316 | |
| 317 | /// When g1t last looked for a newer runner: `Checked 12 min ago`. |
| 318 | pub fn checked(update: &Update) -> String { |
| 319 | if update.checking { |
| 320 | return "Checking for updates…".into(); |
| 321 | } |
| 322 | let Some(ago) = update.checked_ago else { return "Not checked yet".into() }; |
| 323 | let plural = |n: u64, one: &str| if n == 1 { format!("Checked 1 {one} ago") } else { format!("Checked {n} {one}s ago") }; |
| 324 | match ago { |
| 325 | 0..=59 => "Checked just now".into(), |
| 326 | 60..=3599 => format!("Checked {} min ago", ago / 60), |
| 327 | 3600..=86_399 => plural(ago / 3600, "hour"), |
| 328 | _ => plural(ago / 86_400, "day"), |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | /// What becomes of a newer version waiting for `what` (a kind of job, or |
| 333 | /// `runner`), whose job is running when `busy`. None when nothing waits. |
| 334 | fn waiting(update: &Update, what: &str, busy: bool) -> Option<String> { |
| 335 | let version = update.ready.as_deref()?; |
| 336 | if update.installing.iter().any(|kind| kind == what) { |
| 337 | return some(format!("Installing {version}")); |
| 338 | } |
| 339 | if !update.waiting.iter().any(|kind| kind == what) { |
| 340 | return None; |
| 341 | } |
| 342 | some(if busy { format!("{version} ready, installs after this job") } else { format!("{version} ready, installs in a moment") }) |
| 343 | } |
| 344 | |
| 345 | fn update_line(facts: &Facts, code: Code) -> UpdateLine { |
| 346 | let update = &facts.update; |
| 347 | UpdateLine { |
| 348 | checked_at: update.checked_at.clone(), |
| 349 | checked: checked(update), |
| 350 | checking: update.checking, |
| 351 | ready: update.ready.clone(), |
| 352 | note: waiting(update, "runner", code == Code::Busy), |
| 353 | check: CHECK_UPDATES, |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | const START: Action = Action::waits("start", "Start"); |
| 358 | const TURN_ON: Action = Action::waits("start", "Turn on"); |
| 359 | const RETRY: Action = Action::waits("retry", "Retry"); |
| 360 | const CHECK_NOW: Action = Action::waits("retry", "Check now"); |
| 361 | /// Looks for a newer runner now, and installs it as soon as its kinds of job are idle. |
| 362 | const CHECK_UPDATES: Action = Action::waits("check_updates", "Check for runner updates"); |
| 363 | const CHECK_AGAIN: Action = Action::waits("check_updates", "Check again"); |
| 364 | /// Removes the sandbox this computer has (`sandbox down --delete`, in the |
| 365 | /// app's own folder for it) and starts the app's own: what the command a |
| 366 | /// person would type does, without needing to know where that folder is. |
| 367 | const REMOVE_SANDBOX: Action = Action::waits("remove_sandbox", "Remove it and start again").confirming( |
| 368 | "Remove this computer's sandbox? Its VM and registration go, and g1t makes its own in their place, registered with your workspace. Its old runners stay listed on g1t until you remove them there.", |
| 369 | ); |
| 370 | const RESTART: Action = Action::waits("restart", "Restart"); |
| 371 | const SIGN_IN: Action = Action::now("sign_in", "Sign in"); |
| 372 | const CHOOSE_WORKSPACE: Action = Action::now("choose_workspace", "Choose workspace"); |
| 373 | const NEW_WORKSPACE: Action = Action::now("new_workspace", "Create a workspace"); |
| 374 | const CHOOSE_BACKEND: Action = Action::now("choose_backend", "Choose how jobs run"); |
| 375 | /// Docker takes a while to start; the runner's status says when it has. |
| 376 | const OPEN_DOCKER: Action = Action::waits("open_docker", "Open Docker"); |
| 377 | const GET_DOCKER: Action = Action::now("get_docker", "Get Docker"); |
| 378 | const INSTALL_SYSBOX: Action = Action::now("install_sysbox", "Install Sysbox"); |
| 379 | const COPY_WSL_INSTALL: Action = Action::now("copy_wsl_install", "Copy command"); |
| 380 | const COPY_VFKIT_INSTALL: Action = Action::now("copy_vfkit_install", "Copy command"); |
| 381 | const WINDOWS_FEATURES: Action = Action::now("windows_features", "Open Windows features"); |
| 382 | const LEARN_MORE: Action = Action::now("learn_more", "Learn more"); |
| 383 | const VIEW_LOG: Action = Action::now("view_log", "View log"); |
| 384 | const REPORT: Action = Action::now("report", "Report"); |
| 385 | const VIEW_RUNNERS: Action = Action::now("view_runners", "View on g1t"); |
| 386 | /// What Windows can do itself for the sandbox, at once, behind its own |
| 387 | /// administrator prompt (setup.rs): busy until the card says it is setting up. |
| 388 | const SET_UP: Action = Action::waits("set_up", "Set up"); |
| 389 | /// Asked with Windows' own dialog (setup.rs), which no page can answer for |
| 390 | /// the person, rather than a page's: done once the app has it. |
| 391 | const RESTART_WINDOWS: Action = Action::now("restart_windows", "Restart now"); |
| 392 | const RESTART_LATER: Action = Action::waits("restart_later", "Later"); |
| 393 | const WINDOWS_UPDATE: Action = Action::now("windows_update", "Open Windows Update"); |
| 394 | const WINDOWS_ACTIVATION: Action = Action::now("windows_activation", "Open Activation"); |
| 395 | const FIRMWARE_HELP: Action = Action::now("firmware_help", "How to turn it on"); |
| 396 | const NESTED_HELP: Action = Action::now("nested_help", "How to turn it on"); |
| 397 | const SETUP_HELP: Action = Action::now("setup_help", "Learn more"); |
| 398 | const COPY_KVM_LOAD: Action = Action::now("copy_kvm_load", "Copy command"); |
| 399 | const COPY_KVM_GROUP: Action = Action::now("copy_kvm_group", "Copy command"); |
| 400 | const COPY_QEMU_INSTALL: Action = Action::now("copy_qemu_install", "Copy command"); |
| 401 | const GET_HOMEBREW: Action = Action::now("get_homebrew", "Get Homebrew"); |
| 402 | const SOFTWARE_UPDATE: Action = Action::now("software_update", "Open Software Update"); |
| 403 | const OPEN_RUNNER: Action = Action::now("open_runner", "See what's missing"); |
| 404 | |
| 405 | fn some(text: impl Into<String>) -> Option<String> { |
| 406 | Some(text.into()) |
| 407 | } |
| 408 | |
| 409 | /// A wait, said: `30 seconds`, `5 minutes`. |
| 410 | fn said_wait(seconds: u64) -> String { |
| 411 | match seconds { |
| 412 | 0..=1 => "a moment".into(), |
| 413 | 2..=89 => format!("{seconds} seconds"), |
| 414 | _ => format!("{} minutes", seconds.div_ceil(60)), |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | /// The catalogue. |
| 419 | pub fn diagnose(code: Code, facts: &Facts) -> Diagnosis { |
| 420 | let workspace = facts.workspace.as_deref().unwrap_or("your workspace"); |
| 421 | let detail = facts.detail.clone(); |
| 422 | let capabilities = facts.sandbox.as_ref().map(|status| capabilities(status, facts)).unwrap_or_default(); |
| 423 | let (tone, title, cause, fix, actions): (Tone, String, Option<String>, Option<String>, Vec<Action>) = match code { |
| 424 | Code::Off => ( |
| 425 | Tone::Off, |
| 426 | "Not running: turned off".into(), |
| 427 | some("Running jobs on this computer is turned off in g1t's settings."), |
| 428 | some("Turn it on to run your workspace's self-hosted jobs here while g1t is open."), |
| 429 | vec![TURN_ON], |
| 430 | ), |
| 431 | Code::Stopped => ( |
| 432 | Tone::Off, |
| 433 | "Not running: stopped".into(), |
| 434 | some("You stopped the runner. It starts again when you press Start, or when g1t next opens."), |
| 435 | None, |
| 436 | vec![START], |
| 437 | ), |
| 438 | Code::KindsStopped => ( |
| 439 | Tone::Off, |
| 440 | "Not running: every kind of job is stopped".into(), |
| 441 | some("You stopped each kind of job this computer can run."), |
| 442 | some("Start one to run jobs here again."), |
| 443 | vec![], |
| 444 | ), |
| 445 | Code::Starting => (Tone::Working, "Starting".into(), detail, None, vec![]), |
| 446 | Code::Registering => ( |
| 447 | Tone::Working, |
| 448 | format!("Registering with {workspace}"), |
| 449 | some(format!("g1t is registering {} as a runner for {workspace}.", facts.machine)), |
| 450 | some("Keep g1t open and signed in; it takes a few seconds."), |
| 451 | vec![], |
| 452 | ), |
| 453 | Code::SignedOut => ( |
| 454 | Tone::Warning, |
| 455 | "Not running: signed out".into(), |
| 456 | some("This computer isn't registered yet, and registering it needs you signed in to g1t."), |
| 457 | some("Sign in, as an owner of the workspace the runner is for."), |
| 458 | vec![SIGN_IN], |
| 459 | ), |
| 460 | Code::NoWorkspace => ( |
| 461 | Tone::Warning, |
| 462 | "Not running: no workspace to run jobs for".into(), |
| 463 | some("Only a workspace's owners add runners to it, and you don't own one."), |
| 464 | some("Ask an owner of your workspace to run it, or create a workspace of your own."), |
| 465 | vec![NEW_WORKSPACE], |
| 466 | ), |
| 467 | Code::NotAllowed => ( |
| 468 | Tone::Problem, |
| 469 | format!("Couldn't register with {workspace}"), |
| 470 | some(format!("You can't add runners to {workspace}: only its owners can.")), |
| 471 | some(format!("Ask an owner of {workspace} to make you one, or choose a workspace you own.")), |
| 472 | vec![CHOOSE_WORKSPACE], |
| 473 | ), |
| 474 | Code::RegisterFailed => ( |
| 475 | Tone::Problem, |
| 476 | format!("Couldn't register with {workspace}"), |
| 477 | detail.or_else(|| some("g1t didn't make a registration for this computer.")), |
| 478 | some("g1t asks again by itself in a few minutes; Retry asks now. Its log says more."), |
| 479 | vec![RETRY, VIEW_LOG], |
| 480 | ), |
| 481 | Code::Idle => ( |
| 482 | Tone::Ok, |
| 483 | "Waiting for jobs".into(), |
| 484 | some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)), |
| 485 | None, |
| 486 | vec![VIEW_RUNNERS], |
| 487 | ), |
| 488 | Code::Busy => ( |
| 489 | Tone::Working, |
| 490 | format!("Running {}", facts.job.as_deref().unwrap_or("a job")), |
| 491 | some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)), |
| 492 | None, |
| 493 | vec![VIEW_RUNNERS], |
| 494 | ), |
| 495 | Code::NoDocker => { |
| 496 | let app = match facts.platform.as_str() { |
| 497 | "windows" | "macos" => "Docker Desktop", |
| 498 | _ => "Docker", |
| 499 | }; |
| 500 | ( |
| 501 | Tone::Problem, |
| 502 | "Not running: Docker isn't running".into(), |
| 503 | some(format!("Running jobs {} needs Docker, and Docker isn't running on this computer, or isn't installed.", facts.backend)), |
| 504 | some(format!("Start {app}, or install it. The runner starts by itself once Docker answers.")), |
| 505 | if app == "Docker Desktop" { vec![OPEN_DOCKER, GET_DOCKER] } else { vec![GET_DOCKER] }, |
| 506 | ) |
| 507 | } |
| 508 | Code::NoLauncher => ( |
| 509 | Tone::Warning, |
| 510 | "Not running: this g1t-runner has no sandbox".into(), |
| 511 | some("The g1t-runner g1t downloaded is older than its sandbox launcher, so it can't make a sandbox."), |
| 512 | some("g1t looks for a newer one by itself every few minutes; Retry looks now. Or choose to run jobs in Docker or directly on this computer: neither is a sandbox, and settings say what each can reach."), |
| 513 | vec![RETRY, CHOOSE_BACKEND], |
| 514 | ), |
| 515 | Code::NoWsl => ( |
| 516 | Tone::Problem, |
| 517 | "Not running: WSL isn't installed".into(), |
| 518 | some("Linux jobs run in a WSL distribution of the runner's own, and WSL isn't installed on this computer."), |
| 519 | some(format!("Run `{}` in an admin terminal, then restart Windows. It works on every edition of Windows 11, Home included.", launcher::WSL_INSTALL)), |
| 520 | vec![COPY_WSL_INSTALL, LEARN_MORE], |
| 521 | ), |
| 522 | Code::WslMirrored => ( |
| 523 | Tone::Problem, |
| 524 | "Not running: WSL uses mirrored networking".into(), |
| 525 | some("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost, so the sandbox won't start."), |
| 526 | some("Remove networkingMode=mirrored from %USERPROFILE%\\.wslconfig, run `wsl --shutdown`, then Retry."), |
| 527 | vec![RETRY, LEARN_MORE], |
| 528 | ), |
| 529 | Code::NoVirtualization => ( |
| 530 | Tone::Problem, |
| 531 | "Not running: virtualization is off".into(), |
| 532 | some("The sandbox is a small virtual machine, and this computer has virtualization turned off."), |
| 533 | some(if facts.platform == "windows" { |
| 534 | "Turn on virtualization (Intel VT-x or AMD-V) in this computer's firmware settings, and Virtual Machine Platform in Windows features; then restart." |
| 535 | } else { |
| 536 | "Turn on virtualization (Intel VT-x or AMD-V) in this computer's firmware settings, then restart." |
| 537 | }), |
| 538 | if facts.platform == "windows" { vec![WINDOWS_FEATURES, LEARN_MORE] } else { vec![RETRY, LEARN_MORE] }, |
| 539 | ), |
| 540 | Code::NoVfkit => ( |
| 541 | Tone::Problem, |
| 542 | "Not running: vfkit isn't installed".into(), |
| 543 | some("Linux jobs run in a small VM that vfkit runs with Apple's Virtualization framework, and vfkit isn't installed."), |
| 544 | some(format!("Run `{}` in Terminal (macOS 13 or later). The runner starts by itself once it's there.", launcher::VFKIT_INSTALL)), |
| 545 | vec![COPY_VFKIT_INSTALL, LEARN_MORE], |
| 546 | ), |
| 547 | Code::NoSysbox => ( |
| 548 | Tone::Problem, |
| 549 | "Not running: no Sysbox or KVM".into(), |
| 550 | some("The sandbox on Linux is the runner's image under Sysbox, or a VM under QEMU with KVM, and this computer has neither."), |
| 551 | some("Install Sysbox 0.7 or later, or turn on virtualization and install QEMU so /dev/kvm exists. The runner starts by itself once one is there."), |
| 552 | vec![INSTALL_SYSBOX, CHOOSE_BACKEND], |
| 553 | ), |
| 554 | Code::NeedsSetup => { |
| 555 | // The first kind of job that waits on something, in its card's words. |
| 556 | let first = capabilities.iter().find(|kind| kind.enabled && kind.headline.is_some() && matches!(kind.tone, Tone::Warning | Tone::Problem | Tone::Working)); |
| 557 | match first { |
| 558 | Some(kind) => ( |
| 559 | if kind.state == KindState::Starting { Tone::Working } else { Tone::Warning }, |
| 560 | format!("Not running: {}", lowered(kind.headline.as_deref().unwrap_or_default())), |
| 561 | kind.cause.clone(), |
| 562 | kind.fix.clone(), |
| 563 | if kind.actions.is_empty() { vec![OPEN_RUNNER] } else { kind.actions.clone() }, |
| 564 | ), |
| 565 | None => ( |
| 566 | Tone::Warning, |
| 567 | "Not running: this computer needs setting up".into(), |
| 568 | some("The sandbox needs something this computer doesn't have yet."), |
| 569 | some("Each kind of job's card says what, and sets up what it can."), |
| 570 | vec![OPEN_RUNNER], |
| 571 | ), |
| 572 | } |
| 573 | } |
| 574 | Code::WaitingForImage => { |
| 575 | let (release, image) = detail.as_deref().and_then(launcher::missing_images).map_or((None, None), |(release, image)| (Some(release), image)); |
| 576 | let release = release.map_or_else(|| "The newest g1t-runner".to_owned(), |release| format!("g1t-runner {release}")); |
| 577 | ( |
| 578 | Tone::Warning, |
| 579 | "Waiting for the sandbox's image".into(), |
| 580 | some(match image { |
| 581 | Some(image) => format!("{release} is published without the sandbox's image for this kind of computer ({image}) yet, so Linux jobs can't start."), |
| 582 | None => format!("{release} is published without the sandbox's images yet, so Linux jobs can't start."), |
| 583 | }), |
| 584 | some("g1t checks again every few minutes and starts Linux jobs once they're out. There's nothing for you to do."), |
| 585 | vec![CHECK_NOW], |
| 586 | ) |
| 587 | } |
| 588 | Code::ImageFailed => { |
| 589 | let said = detail.clone().unwrap_or_default().to_ascii_lowercase(); |
| 590 | if said.contains("sha-256") || said.contains("signature") { |
| 591 | ( |
| 592 | Tone::Problem, |
| 593 | "Not running: the sandbox's image didn't check out".into(), |
| 594 | some("The sandbox's image that downloaded isn't the one g1t's release signed, so it wasn't used."), |
| 595 | some(format!( |
| 596 | "g1t downloads it again by itself{}. If this keeps happening, something between this computer and {} is changing downloads, such as a proxy or antivirus: report it.", |
| 597 | facts.wait_seconds.map(|wait| format!(" in {}", said_wait(wait))).unwrap_or_default(), |
| 598 | facts.server |
| 599 | )), |
| 600 | vec![RETRY, REPORT], |
| 601 | ) |
| 602 | } else { |
| 603 | ( |
| 604 | Tone::Problem, |
| 605 | "Not running: the sandbox's image didn't download".into(), |
| 606 | some(match &detail { |
| 607 | Some(detail) => format!("The sandbox's image couldn't be downloaded: {detail}"), |
| 608 | None => "The sandbox's image couldn't be downloaded.".into(), |
| 609 | }), |
| 610 | some(format!( |
| 611 | "g1t tries again by itself{}. Check that this computer can reach {}; Retry tries now.", |
| 612 | facts.wait_seconds.map(|wait| format!(" in {}", said_wait(wait))).unwrap_or_default(), |
| 613 | facts.server |
| 614 | )), |
| 615 | vec![RETRY, VIEW_LOG], |
| 616 | ) |
| 617 | } |
| 618 | } |
| 619 | Code::SandboxUnavailable => ( |
| 620 | Tone::Problem, |
| 621 | "Not running: the sandbox can't run here".into(), |
| 622 | detail.or_else(|| some("The sandbox can't be made on this computer.")), |
| 623 | facts.fix.clone().or_else(|| some("Choose to run jobs in Docker or directly on this computer instead: neither is a sandbox, and settings say what each can reach.")), |
| 624 | vec![RETRY, LEARN_MORE, CHOOSE_BACKEND], |
| 625 | ), |
| 626 | Code::SandboxFailed => ( |
| 627 | Tone::Problem, |
| 628 | "Not running: the sandbox failed".into(), |
| 629 | detail.or_else(|| some("The sandbox stopped, and didn't say why.")), |
| 630 | some(match facts.wait_seconds { |
| 631 | Some(wait) => format!("It starts again by itself in {}. If it keeps stopping, its log says why; Retry starts it now.", said_wait(wait)), |
| 632 | None => "Retry starts it again; its log says why it stopped.".into(), |
| 633 | }), |
| 634 | vec![RETRY, VIEW_LOG], |
| 635 | ), |
| 636 | Code::SandboxTaken => ( |
| 637 | Tone::Warning, |
| 638 | "Not running: this computer has another sandbox".into(), |
| 639 | some("This computer already has a g1t-runner sandbox that this copy of g1t has no record of making, such as one from before g1t was installed again, or one started with `g1t-runner sandbox up`. A computer has room for one."), |
| 640 | some("Remove it and start again: g1t removes it, with the jobs' VM and its registration, and makes its own, registered with your workspace. Its old runners stay listed on g1t, offline, until you remove them there. Or keep it, and turn off running jobs in g1t."), |
| 641 | vec![REMOVE_SANDBOX, LEARN_MORE], |
| 642 | ), |
| 643 | Code::Updating => (Tone::Working, "Updating".into(), detail, some("The runner starts again as soon as it's done."), vec![]), |
| 644 | Code::UpdateFailed => ( |
| 645 | Tone::Warning, |
| 646 | "Update failed".into(), |
| 647 | detail, |
| 648 | some("The runner keeps running the version it has, and g1t tries the update again within the hour. Check again tries now; if it keeps failing, report it."), |
| 649 | vec![CHECK_AGAIN, REPORT], |
| 650 | ), |
| 651 | Code::UpdateRefused => { |
| 652 | let why = detail.unwrap_or_else(|| "its signature didn't check out".into()); |
| 653 | let tampered = format!( |
| 654 | "If this keeps happening, something between this computer and {} is changing downloads, such as a proxy or antivirus: report it.", |
| 655 | facts.server |
| 656 | ); |
| 657 | match facts.update.installed.as_deref() { |
| 658 | Some(version) => ( |
| 659 | Tone::Warning, |
| 660 | "Update refused: not signed by g1t".into(), |
| 661 | some(format!("The newest g1t-runner release from {} isn't signed with g1t's release key ({why}), so g1t didn't install it.", facts.server)), |
| 662 | some(format!("The runner keeps running g1t-runner {version}, and g1t checks again within the hour. {tampered}")), |
| 663 | vec![CHECK_AGAIN, REPORT], |
| 664 | ), |
| 665 | None => ( |
| 666 | Tone::Problem, |
| 667 | "Not running: the runner's release isn't signed by g1t".into(), |
| 668 | some(format!("The g1t-runner release from {} isn't signed with g1t's release key ({why}), so g1t didn't install it, and has no runner to start.", facts.server)), |
| 669 | some(format!("g1t tries again by itself in a few minutes; Retry tries now. {tampered}")), |
| 670 | vec![RETRY, REPORT], |
| 671 | ), |
| 672 | } |
| 673 | } |
| 674 | Code::Offline => ( |
| 675 | Tone::Warning, |
| 676 | format!("Offline: {} unreachable", facts.server), |
| 677 | some(format!("This computer can't reach {}, so the runner can't take jobs.", facts.server)), |
| 678 | some("Check the connection. The runner carries on by itself when it's back."), |
| 679 | vec![RETRY], |
| 680 | ), |
| 681 | Code::Crashed => ( |
| 682 | Tone::Problem, |
| 683 | match facts.exit_code { |
| 684 | Some(exit) => format!("Runner stopped (exit code {exit})"), |
| 685 | None => "Runner stopped".into(), |
| 686 | }, |
| 687 | detail, |
| 688 | Some(match facts.wait_seconds { |
| 689 | Some(wait) => format!("It starts again by itself in {}. If it keeps stopping, its log says why; Restart starts it now.", said_wait(wait)), |
| 690 | None => "Restart starts it again; its log says why it stopped.".into(), |
| 691 | }), |
| 692 | vec![RESTART, VIEW_LOG], |
| 693 | ), |
| 694 | }; |
| 695 | let in_capabilities = !capabilities.is_empty() |
| 696 | && match code { |
| 697 | Code::Idle | Code::Busy | Code::KindsStopped | Code::NeedsSetup => true, |
| 698 | Code::Starting | Code::Updating => facts.live, |
| 699 | // Said by a card, for its kind of job, word for word. |
| 700 | Code::NoWsl |
| 701 | | Code::WslMirrored |
| 702 | | Code::NoVirtualization |
| 703 | | Code::NoVfkit |
| 704 | | Code::NoSysbox |
| 705 | | Code::WaitingForImage |
| 706 | | Code::ImageFailed |
| 707 | | Code::SandboxUnavailable |
| 708 | | Code::SandboxFailed => cause.is_some() && capabilities.iter().any(|c| c.cause == cause), |
| 709 | _ => false, |
| 710 | }; |
| 711 | let update = Some(update_line(facts, code)); |
| 712 | Diagnosis { code, tone, title, cause, fix, actions, capabilities, in_capabilities, update } |
| 713 | } |
| 714 | |
| 715 | /// What the app calls a sandbox's problem with Linux jobs, from the |
| 716 | /// launcher's sentence about it, which goes with it as its detail. |
| 717 | pub fn sandbox_code(why: Why) -> Code { |
| 718 | match why { |
| 719 | Why::NoWsl => Code::NoWsl, |
| 720 | Why::WslMirrored => Code::WslMirrored, |
| 721 | Why::NoVirtualization => Code::NoVirtualization, |
| 722 | Why::NoVfkit => Code::NoVfkit, |
| 723 | Why::NoSysboxOrKvm => Code::NoSysbox, |
| 724 | Why::NoImages => Code::WaitingForImage, |
| 725 | Why::Image => Code::ImageFailed, |
| 726 | _ => Code::SandboxFailed, |
| 727 | } |
| 728 | } |
| 729 | |
| 730 | /// The code for what is wrong with Linux jobs, as a status says it, and |
| 731 | /// the launcher's own words for it. |
| 732 | pub fn sandbox_trouble(status: &Status) -> Option<(Code, String, Option<String>)> { |
| 733 | let (cause, fix) = status.trouble()?; |
| 734 | let code = match launcher::why(&cause) { |
| 735 | Why::Other if !status.linux().available => Code::SandboxUnavailable, |
| 736 | why => sandbox_code(why), |
| 737 | }; |
| 738 | Some((code, cause, fix)) |
| 739 | } |
| 740 | |
| 741 | /// Each kind of job the sandbox runs, as a card of its own. |
| 742 | fn capabilities(status: &Status, facts: &Facts) -> Vec<CapabilityLine> { |
| 743 | let listed = if status.capabilities.is_empty() { vec![status.linux()] } else { status.capabilities.clone() }; |
| 744 | listed.iter().map(|capability| capability_line(status, capability, facts)).collect() |
| 745 | } |
| 746 | |
| 747 | /// `Linux jobs`. |
| 748 | pub fn kind_label(id: &str) -> String { |
| 749 | match id { |
| 750 | "linux" => "Linux jobs".to_owned(), |
| 751 | "windows" => "Windows jobs".to_owned(), |
| 752 | "macos" => "macOS jobs".to_owned(), |
| 753 | other => format!("{other} jobs"), |
| 754 | } |
| 755 | } |
| 756 | |
| 757 | /// What a kind of job is, in a line, by how the sandbox makes it. |
| 758 | fn about(id: &str, backend: Option<&str>) -> String { |
| 759 | match (id, backend) { |
| 760 | ("linux", Some("sysbox")) => "The runner under Sysbox, each job in a fresh container".into(), |
| 761 | ("linux", _) => "A Linux VM of its own, each job in a fresh container".into(), |
| 762 | ("windows", _) => "Each job in a Windows Sandbox, discarded after".into(), |
| 763 | ("macos", _) => "Each job in a macOS VM of its own, discarded after".into(), |
| 764 | _ => "Each job sandboxed".into(), |
| 765 | } |
| 766 | } |
| 767 | |
| 768 | /// A headline inside a sentence: `WSL isn't installed` stays, `Virtualization is off` is `virtualization is off`. |
| 769 | fn lowered(headline: &str) -> String { |
| 770 | let mut letters = headline.chars(); |
| 771 | let Some(first) = letters.next() else { return String::new() }; |
| 772 | // An initialism (WSL, KVM, QEMU) keeps its capitals. |
| 773 | if headline.chars().nth(1).is_some_and(char::is_uppercase) { |
| 774 | return headline.to_owned(); |
| 775 | } |
| 776 | first.to_lowercase().chain(letters).collect() |
| 777 | } |
| 778 | |
| 779 | /// What a kind of job's card says of what this computer lacks for it. |
| 780 | struct NeedWords { |
| 781 | state: KindState, |
| 782 | tone: Tone, |
| 783 | title: &'static str, |
| 784 | headline: String, |
| 785 | cause: String, |
| 786 | fix: Option<String>, |
| 787 | actions: Vec<Action>, |
| 788 | } |
| 789 | |
| 790 | /// The card's words for what this computer lacks (the launcher's `needs`, |
| 791 | /// first what must come first): what Set up does when Windows can do all |
| 792 | /// of it, else the first, with exact steps. None for a need this app |
| 793 | /// doesn't know, from a newer launcher: its own words say it then. |
| 794 | fn need_words(needs: &[String], id: &str, facts: &Facts) -> Option<NeedWords> { |
| 795 | let label = kind_label(id); |
| 796 | let first = needs.first()?.as_str(); |
| 797 | let words = |headline: &str, cause: String, fix: String, actions: Vec<Action>| NeedWords { |
| 798 | state: KindState::Unavailable, |
| 799 | tone: Tone::Warning, |
| 800 | title: "Needs setting up", |
| 801 | headline: headline.into(), |
| 802 | cause, |
| 803 | fix: Some(fix), |
| 804 | actions, |
| 805 | }; |
| 806 | if super::setup::fixable(needs) { |
| 807 | // What one press does: for every kind of job that is on, this one's included. |
| 808 | let all = if facts.setup.needs.is_empty() { needs } else { &facts.setup.needs[..] }; |
| 809 | let plan = super::setup::plan_of(all); |
| 810 | if let Some(doing) = &facts.setup.running { |
| 811 | return Some(NeedWords { |
| 812 | state: KindState::Starting, |
| 813 | tone: Tone::Working, |
| 814 | title: "Setting up", |
| 815 | headline: "Setting up".into(), |
| 816 | cause: format!("g1t {doing}. Windows asks you to allow it first, and it takes a few minutes."), |
| 817 | fix: None, |
| 818 | actions: vec![], |
| 819 | }); |
| 820 | } |
| 821 | let has = |need: &str| needs.iter().any(|n| n == need); |
| 822 | let headline = if has("wsl") { |
| 823 | "WSL isn't installed" |
| 824 | } else if has("vm_platform") { |
| 825 | "Virtual Machine Platform is off" |
| 826 | } else if has("wsl_update") { |
| 827 | "WSL needs an update" |
| 828 | } else { |
| 829 | "Windows Sandbox is off" |
| 830 | }; |
| 831 | let cause = if id == "linux" { |
| 832 | let mut lacks = Vec::new(); |
| 833 | if has("vm_platform") { |
| 834 | lacks.push("Virtual Machine Platform turned on"); |
| 835 | } |
| 836 | if has("wsl") { |
| 837 | lacks.push("WSL installed"); |
| 838 | } else if has("wsl_update") { |
| 839 | lacks.push("a newer WSL"); |
| 840 | } |
| 841 | format!("Linux jobs run in a small Linux VM of their own under WSL 2, which needs {}.", lacks.join(" and ")) |
| 842 | } else { |
| 843 | "Windows jobs each run in a Windows Sandbox of their own, which is turned off.".to_owned() |
| 844 | }; |
| 845 | let restart = if plan.features.is_empty() { "" } else { ", and to restart after" }; |
| 846 | let fix = format!("Set up {} in one go. Windows asks you to allow it{restart}.", plan.said()); |
| 847 | return Some(match &facts.setup.failed { |
| 848 | Some(why) => NeedWords { |
| 849 | cause: format!("Set up didn't finish: {why}"), |
| 850 | fix: Some(format!("{fix} Its log says what happened.")), |
| 851 | actions: vec![SET_UP, VIEW_LOG], |
| 852 | ..words(headline, String::new(), String::new(), vec![]) |
| 853 | }, |
| 854 | None => words(headline, cause, fix, vec![SET_UP, SETUP_HELP]), |
| 855 | }); |
| 856 | } |
| 857 | let linux_host = facts.platform == "linux"; |
| 858 | Some(match first { |
| 859 | "restart" => NeedWords { |
| 860 | title: "Restart needed", |
| 861 | ..words( |
| 862 | "Restart to finish setting up", |
| 863 | format!("Windows has turned on what {label} need, and it takes effect once Windows restarts."), |
| 864 | if facts.setup.later { |
| 865 | "Restart when it suits you: g1t opens again after, and carries on by itself.".into() |
| 866 | } else { |
| 867 | "Restart now, or later: g1t opens again after the restart and carries on by itself.".into() |
| 868 | }, |
| 869 | if facts.setup.later { vec![RESTART_WINDOWS] } else { vec![RESTART_WINDOWS, RESTART_LATER] }, |
| 870 | ) |
| 871 | }, |
| 872 | "windows_update" if id == "windows" => words( |
| 873 | "Windows needs an update", |
| 874 | "Windows jobs use Windows Sandbox's command line, which came with Windows 11 24H2, and this Windows is older.".into(), |
| 875 | "Update Windows to 24H2 or later in Windows Update, and restart. g1t carries on after.".into(), |
| 876 | vec![WINDOWS_UPDATE, CHECK_NOW], |
| 877 | ), |
| 878 | "windows_update" => words( |
| 879 | "Windows needs an update", |
| 880 | "Linux jobs run in a small Linux VM under WSL 2, and this Windows is older than the first with it, Windows 10 version 2004.".into(), |
| 881 | "Update Windows in Windows Update, and restart. g1t sets up the rest after.".into(), |
| 882 | vec![WINDOWS_UPDATE, CHECK_NOW], |
| 883 | ), |
| 884 | "virtualization" => words( |
| 885 | "Virtualization is off", |
| 886 | if linux_host { |
| 887 | "The sandbox is a small virtual machine, and this computer's processor has virtualization (Intel VT-x or AMD-V) turned off in its firmware, or has none.".into() |
| 888 | } else { |
| 889 | "The sandbox is a small virtual machine, and this computer has virtualization (Intel VT-x or AMD-V) turned off in its firmware, where only you can turn it on.".into() |
| 890 | }, |
| 891 | "Restart into the firmware settings (BIOS or UEFI: a key such as F2, F10, Del or Esc as the computer starts), turn on Intel Virtualization Technology or SVM Mode, then save and restart. g1t carries on after.".into(), |
| 892 | vec![FIRMWARE_HELP, CHECK_NOW], |
| 893 | ), |
| 894 | "nested_virtualization" => words( |
| 895 | "This VM can't run the sandbox", |
| 896 | "g1t is running in a virtual machine whose host doesn't pass virtualization through to it, and the sandbox is a virtual machine inside it.".into(), |
| 897 | "On the host, turn on nested virtualization for this VM, then start it again. On Hyper-V, with the VM off: Set-VMProcessor -VMName <name> -ExposeVirtualizationExtensions $true.".into(), |
| 898 | vec![NESTED_HELP, CHECK_NOW], |
| 899 | ), |
| 900 | "windows_edition" => NeedWords { |
| 901 | tone: Tone::Off, |
| 902 | title: "Not available", |
| 903 | ..words( |
| 904 | "Not in Windows Home", |
| 905 | "Windows jobs each run in a Windows Sandbox, and Windows Home doesn't have it.".into(), |
| 906 | "Linux jobs run here all the same. Windows jobs need Windows 11 Pro, Enterprise or Education.".into(), |
| 907 | vec![WINDOWS_ACTIVATION], |
| 908 | ) |
| 909 | }, |
| 910 | "kvm_module" => words( |
| 911 | "KVM isn't loaded", |
| 912 | "Linux jobs run in a small VM under QEMU with KVM, and /dev/kvm isn't there, though this processor can run virtual machines.".into(), |
| 913 | format!("Load it with `{}` (Copy command), then Check now. If it says the firmware disabled it, turn on VT-x or AMD-V there.", super::launcher::KVM_LOAD), |
| 914 | vec![COPY_KVM_LOAD, CHECK_NOW], |
| 915 | ), |
| 916 | "kvm_group" => words( |
| 917 | "No access to KVM", |
| 918 | "/dev/kvm is here, but your account isn't allowed to use it.".into(), |
| 919 | format!("Run `{}` (Copy command), then log out and back in.", super::launcher::KVM_GROUP), |
| 920 | vec![COPY_KVM_GROUP, CHECK_NOW], |
| 921 | ), |
| 922 | "qemu" => words( |
| 923 | "QEMU isn't installed", |
| 924 | "Linux jobs run in a small VM under QEMU, which isn't installed.".into(), |
| 925 | match &facts.qemu_install { |
| 926 | Some(command) => format!("Install it with `{command}` (Copy command), then Check now. Or install Sysbox 0.7 or later instead."), |
| 927 | None => "Install QEMU with your package manager, then Check now. Or install Sysbox 0.7 or later instead.".into(), |
| 928 | }, |
| 929 | if facts.qemu_install.is_some() { vec![COPY_QEMU_INSTALL, CHECK_NOW] } else { vec![CHECK_NOW, INSTALL_SYSBOX] }, |
| 930 | ), |
| 931 | "macos_update" => words( |
| 932 | "macOS needs an update", |
| 933 | "Linux jobs run in a small VM with Apple's Virtualization framework, through vfkit, which needs macOS 13 or later.".into(), |
| 934 | "Update macOS in Software Update, then open g1t again.".into(), |
| 935 | vec![SOFTWARE_UPDATE], |
| 936 | ), |
| 937 | "homebrew" => words( |
| 938 | "vfkit isn't installed", |
| 939 | "Linux jobs run in a small VM under vfkit, which installs with Homebrew, and neither is on this Mac.".into(), |
| 940 | format!("Install Homebrew from brew.sh (Get Homebrew), then run `{}` in Terminal (Copy command). The runner starts by itself once vfkit is there.", launcher::VFKIT_INSTALL), |
| 941 | vec![GET_HOMEBREW, COPY_VFKIT_INSTALL], |
| 942 | ), |
| 943 | "vfkit" => words( |
| 944 | "vfkit isn't installed", |
| 945 | "Linux jobs run in a small VM that vfkit runs with Apple's Virtualization framework, and vfkit isn't installed.".into(), |
| 946 | format!("Run `{}` in Terminal (Copy command). The runner starts by itself once it's there.", launcher::VFKIT_INSTALL), |
| 947 | vec![COPY_VFKIT_INSTALL, CHECK_NOW], |
| 948 | ), |
| 949 | _ => return None, |
| 950 | }) |
| 951 | } |
| 952 | |
| 953 | /// A catalogue title as a card's headline: `Not running: virtualization is off` is `Virtualization is off`. |
| 954 | fn headline(title: &str) -> String { |
| 955 | let said = title.strip_prefix("Not running: ").unwrap_or(title); |
| 956 | let mut letters = said.chars(); |
| 957 | letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default() |
| 958 | } |
| 959 | |
| 960 | fn capability_line(status: &Status, capability: &launcher::Capability, facts: &Facts) -> CapabilityLine { |
| 961 | let id = capability.id.as_str(); |
| 962 | let label = kind_label(id); |
| 963 | let enabled = !facts.off.iter().any(|off| off == id); |
| 964 | let per_kind = status.per_kind(); |
| 965 | let mut line = CapabilityLine { |
| 966 | id: id.to_owned(), |
| 967 | label: label.clone(), |
| 968 | tone: Tone::Off, |
| 969 | state: KindState::Stopped, |
| 970 | title: "Stopped".into(), |
| 971 | about: about(id, status.backend.as_deref()), |
| 972 | headline: None, |
| 973 | cause: None, |
| 974 | fix: None, |
| 975 | actions: vec![], |
| 976 | enabled, |
| 977 | available: capability.available, |
| 978 | // Nothing to start or stop where it can't run, nor while the whole runner is |
| 979 | // stopped, whose own Start starts it (one the person stopped can be started). |
| 980 | can_toggle: per_kind && (capability.available || !enabled) && !(facts.held && enabled), |
| 981 | job: None, |
| 982 | progress: None, |
| 983 | isolation: capability.isolation.clone().filter(|_| capability.available), |
| 984 | runner: capability.available.then(|| format!("{}-{id}", facts.machine)), |
| 985 | version: capability.runner_version.clone(), |
| 986 | update: waiting(&facts.update, id, capability.job.is_some()), |
| 987 | }; |
| 988 | let said = |line: &mut CapabilityLine, state: KindState, tone: Tone, cause: Option<String>, fix: Option<String>, actions: Vec<Action>| { |
| 989 | line.state = state; |
| 990 | line.tone = tone; |
| 991 | line.title = match state { |
| 992 | KindState::Busy => "Running a job", |
| 993 | KindState::Idle => "Waiting for jobs", |
| 994 | KindState::Starting => "Starting", |
| 995 | KindState::Downloading => "Downloading image", |
| 996 | KindState::Updating => "Updating", |
| 997 | KindState::Waiting => "Waiting for image", |
| 998 | KindState::Stopped => "Stopped", |
| 999 | KindState::Unavailable => "Not available", |
| 1000 | KindState::Problem => "Problem", |
| 1001 | } |
| 1002 | .into(); |
| 1003 | line.cause = cause; |
| 1004 | line.fix = fix; |
| 1005 | line.actions = actions; |
| 1006 | }; |
| 1007 | let again = |what: &str| format!("g1t {what} again by itself in a few minutes; Retry {what} now."); |
| 1008 | let starting = facts.starting.iter().any(|kind| kind == id) || matches!(capability.state, State::Starting); |
| 1009 | |
| 1010 | // Stopped by the person: nothing is wrong with it. |
| 1011 | if capability.off() || (!enabled && per_kind) { |
| 1012 | said(&mut line, KindState::Stopped, Tone::Off, some(format!("You stopped {label} on this computer. They stay stopped until you start them.")), None, vec![]); |
| 1013 | return line; |
| 1014 | } |
| 1015 | // What this computer lacks for it, by name: said plainly, with Set up |
| 1016 | // for what Windows can do itself, and exact steps for the rest. |
| 1017 | if let Some(words) = need_words(&capability.needs, id, facts) { |
| 1018 | said(&mut line, words.state, words.tone, Some(words.cause), words.fix, words.actions); |
| 1019 | line.title = words.title.into(); |
| 1020 | line.headline = Some(words.headline); |
| 1021 | return line; |
| 1022 | } |
| 1023 | // This computer can't run it, and why. |
| 1024 | if !capability.available { |
| 1025 | if id == "linux" |
| 1026 | && let Some((code, cause, fix)) = sandbox_trouble(status) |
| 1027 | { |
| 1028 | let whole = diagnose(code, &Facts { detail: Some(cause), fix, sandbox: None, ..facts.clone() }); |
| 1029 | said(&mut line, KindState::Unavailable, Tone::Warning, whole.cause, whole.fix, whole.actions); |
| 1030 | line.headline = Some(headline(&whole.title)); |
| 1031 | return line; |
| 1032 | } |
| 1033 | let cause = capability.cause.clone(); |
| 1034 | let (headline, cause, fix, actions) = match cause.as_deref().map(launcher::why) { |
| 1035 | Some(Why::WindowsSandboxOff) => ( |
| 1036 | "Windows Sandbox is off", |
| 1037 | some("Windows Sandbox is turned off, or this edition of Windows has none: Home doesn't."), |
| 1038 | some("On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox in Windows features, then restart."), |
| 1039 | vec![WINDOWS_FEATURES], |
| 1040 | ), |
| 1041 | Some(Why::NoWsb) => ( |
| 1042 | "Windows needs an update", |
| 1043 | some("This Windows has Windows Sandbox, but not its wsb command, which came with Windows 11 24H2."), |
| 1044 | some("Update Windows to 24H2 or later, then restart g1t."), |
| 1045 | vec![], |
| 1046 | ), |
| 1047 | Some(Why::MacNotReleased) => ( |
| 1048 | "Not released yet", |
| 1049 | some("Sandboxed macOS jobs aren't released yet."), |
| 1050 | some("They come with a g1t-runner update, which g1t downloads by itself. There's nothing for you to do."), |
| 1051 | vec![], |
| 1052 | ), |
| 1053 | _ => ("Can't run here", cause, capability.fix.clone(), vec![LEARN_MORE]), |
| 1054 | }; |
| 1055 | said(&mut line, KindState::Unavailable, Tone::Off, cause, fix, actions); |
| 1056 | line.headline = Some(headline.into()); |
| 1057 | return line; |
| 1058 | } |
| 1059 | // Linux jobs that should run and don't, in the catalogue's words. |
| 1060 | if id == "linux" |
| 1061 | && !starting |
| 1062 | && !matches!(capability.state, State::Updating) |
| 1063 | && let Some((code, cause, fix)) = sandbox_trouble(status) |
| 1064 | { |
| 1065 | let wait_seconds = if facts.live { None } else { facts.wait_seconds }; |
| 1066 | let whole = diagnose(code, &Facts { detail: Some(cause), fix, wait_seconds, sandbox: None, ..facts.clone() }); |
| 1067 | let state = if code == Code::WaitingForImage { KindState::Waiting } else { KindState::Problem }; |
| 1068 | said(&mut line, state, whole.tone, whole.cause, whole.fix, whole.actions); |
| 1069 | line.headline = Some(headline(&whole.title)); |
| 1070 | return line; |
| 1071 | } |
| 1072 | // A native kind of job whose runner didn't start, in the launcher's words. |
| 1073 | if id != "linux" && capability.state == State::Error && !starting { |
| 1074 | let cause = capability.cause.clone().or_else(|| some("Its runner didn't start, and didn't say why.")); |
| 1075 | said(&mut line, KindState::Problem, Tone::Problem, cause, some(again("tries")), vec![RETRY, VIEW_LOG]); |
| 1076 | line.headline = Some("Its runner didn't start".into()); |
| 1077 | return line; |
| 1078 | } |
| 1079 | let downloading = facts.download.as_ref().filter(|_| id == "linux"); |
| 1080 | match capability.state { |
| 1081 | _ if starting || capability.state == State::Updating => match downloading { |
| 1082 | Some(download) => { |
| 1083 | said(&mut line, KindState::Downloading, Tone::Working, some("Downloading and checking the sandbox's image, once: it takes a few minutes."), None, vec![]); |
| 1084 | line.progress = Some(download.percent); |
| 1085 | } |
| 1086 | None if capability.state == State::Updating => said(&mut line, KindState::Updating, Tone::Working, some("Taking the newest sandbox image; it takes jobs again when it's done."), None, vec![]), |
| 1087 | None if capability.state == State::Absent => said(&mut line, KindState::Starting, Tone::Working, some(format!("Registering it with {}.", facts.workspace.as_deref().unwrap_or("your workspace"))), None, vec![]), |
| 1088 | None => said(&mut line, KindState::Starting, Tone::Working, None, None, vec![]), |
| 1089 | }, |
| 1090 | State::Running => match &capability.job { |
| 1091 | Some(job) => { |
| 1092 | said(&mut line, KindState::Busy, Tone::Working, None, None, vec![]); |
| 1093 | // Every job a runner takes is on the repository's Actions page: |
| 1094 | // workflow jobs, and the queue, merge check and checks tasks |
| 1095 | // the runner's wire still calls `agent` work. |
| 1096 | let link = (!job.repo.is_empty() && !facts.site.is_empty()).then(|| format!("{}/{}/actions", facts.site.trim_end_matches('/'), job.repo)); |
| 1097 | line.job = Some(JobLine { kind: job.kind.clone(), name: job.name.clone(), repo: job.repo.clone(), link }); |
| 1098 | } |
| 1099 | None => said(&mut line, KindState::Idle, Tone::Ok, None, None, vec![]), |
| 1100 | }, |
| 1101 | // Up while the others run, yet not running: it stopped by itself, |
| 1102 | // or is to be registered. |
| 1103 | State::Stopped | State::Absent if facts.live && status.running() => { |
| 1104 | if capability.state == State::Absent { |
| 1105 | said(&mut line, KindState::Starting, Tone::Working, some(format!("Registering it with {}.", facts.workspace.as_deref().unwrap_or("your workspace"))), None, vec![]); |
| 1106 | } else { |
| 1107 | said(&mut line, KindState::Problem, Tone::Problem, some("Its runner stopped by itself."), some(again("starts it")), vec![RETRY, VIEW_LOG]); |
| 1108 | line.headline = Some("Its runner stopped".into()); |
| 1109 | } |
| 1110 | } |
| 1111 | State::Stopped | State::Absent => said(&mut line, KindState::Stopped, Tone::Off, None, None, vec![]), |
| 1112 | State::Error | State::Unknown | State::Starting | State::Updating => { |
| 1113 | said(&mut line, KindState::Problem, Tone::Problem, capability.cause.clone().or_else(|| status.error.clone()), capability.fix.clone(), vec![RETRY, VIEW_LOG]); |
| 1114 | } |
| 1115 | } |
| 1116 | // The person stopped it, and this launcher can't stop one kind alone yet. |
| 1117 | if !enabled && line.fix.is_none() { |
| 1118 | line.fix = some(format!("This g1t-runner can't stop {label} on their own; the next one can, and g1t downloads it by itself.")); |
| 1119 | } |
| 1120 | line |
| 1121 | } |
| 1122 | |
| 1123 | /// The runner in a few words, for the chip beside the title bar's settings |
| 1124 | /// button, which opens the Runner window: `Runner running`, `Running 2 jobs`. |
| 1125 | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] |
| 1126 | pub struct Chip { |
| 1127 | pub tone: Tone, |
| 1128 | pub label: String, |
| 1129 | } |
| 1130 | |
| 1131 | /// The chip, from the whole runner's diagnosis and its kinds of job: what |
| 1132 | /// it is doing, and whether anything there wants the person. |
| 1133 | pub fn chip(diagnosis: &Diagnosis) -> Chip { |
| 1134 | let jobs = diagnosis.capabilities.iter().filter(|kind| kind.job.is_some()).count().max(usize::from(diagnosis.code == Code::Busy)); |
| 1135 | let wanting = diagnosis.capabilities.iter().any(|kind| kind.enabled && matches!(kind.tone, Tone::Problem | Tone::Warning)); |
| 1136 | let (tone, label) = match diagnosis.code { |
| 1137 | _ if jobs > 1 => (Tone::Working, format!("Running {jobs} jobs")), |
| 1138 | _ if jobs == 1 => (Tone::Working, "Running a job".to_owned()), |
| 1139 | Code::Idle => (if wanting { Tone::Warning } else { Tone::Ok }, "Runner running".to_owned()), |
| 1140 | Code::Starting | Code::Registering => (Tone::Working, "Runner starting".to_owned()), |
| 1141 | Code::Updating => (Tone::Working, "Runner updating".to_owned()), |
| 1142 | Code::UpdateFailed | Code::UpdateRefused => (Tone::Warning, "Runner running".to_owned()), |
| 1143 | Code::Off => (Tone::Off, "Runner off".to_owned()), |
| 1144 | Code::Stopped | Code::KindsStopped => (Tone::Off, "Runner stopped".to_owned()), |
| 1145 | Code::Offline => (Tone::Warning, "Runner offline".to_owned()), |
| 1146 | Code::NeedsSetup if diagnosis.tone == Tone::Working => (Tone::Working, "Runner setting up".to_owned()), |
| 1147 | _ => (diagnosis.tone, "Runner needs you".to_owned()), |
| 1148 | }; |
| 1149 | Chip { tone, label } |
| 1150 | } |
| 1151 | |
| 1152 | /// What the site says of this computer while g1t is closed on it. |
| 1153 | pub fn closed(facts: &Facts) -> Diagnosis { |
| 1154 | Diagnosis { |
| 1155 | code: Code::Off, |
| 1156 | tone: Tone::Off, |
| 1157 | title: format!("g1t is closed on {}", facts.machine), |
| 1158 | cause: some(format!("This runner runs while g1t is open on {}.", facts.machine)), |
| 1159 | fix: some(format!("Open g1t on {} to run jobs there again.", facts.machine)), |
| 1160 | actions: vec![], |
| 1161 | capabilities: vec![], |
| 1162 | in_capabilities: false, |
| 1163 | update: None, |
| 1164 | } |
| 1165 | } |
| 1166 | |
| 1167 | #[cfg(test)] |
| 1168 | mod tests { |
| 1169 | use super::*; |
| 1170 | |
| 1171 | fn facts() -> Facts { |
| 1172 | Facts { |
| 1173 | workspace: Some("acme".into()), |
| 1174 | backend: "sandboxed", |
| 1175 | machine: "build-box".into(), |
| 1176 | server: "g1t.sh".into(), |
| 1177 | site: "https://g1t.sh".into(), |
| 1178 | platform: "windows".into(), |
| 1179 | ..Facts::default() |
| 1180 | } |
| 1181 | } |
| 1182 | |
| 1183 | #[test] |
| 1184 | fn a_fix_that_changes_the_runner_waits_for_its_status() { |
| 1185 | for action in [START, TURN_ON, RETRY, CHECK_NOW, CHECK_UPDATES, CHECK_AGAIN, REMOVE_SANDBOX, RESTART, OPEN_DOCKER] { |
| 1186 | assert!(action.waits, "{} waits for the status that follows", action.id); |
| 1187 | } |
| 1188 | for action in [SIGN_IN, CHOOSE_WORKSPACE, CHOOSE_BACKEND, GET_DOCKER, COPY_WSL_INSTALL, LEARN_MORE, VIEW_LOG, VIEW_RUNNERS] { |
| 1189 | assert!(!action.waits, "{} is done once the app has it", action.id); |
| 1190 | } |
| 1191 | // Only what can't be undone asks first. |
| 1192 | assert!(REMOVE_SANDBOX.confirm.is_some_and(|question| question.ends_with('.') && question.contains('?'))); |
| 1193 | assert!(RETRY.confirm.is_none()); |
| 1194 | let said = serde_json::to_value(RETRY).unwrap(); |
| 1195 | assert_eq!(said, serde_json::json!({ "id": "retry", "label": "Retry", "waits": true })); |
| 1196 | } |
| 1197 | |
| 1198 | #[test] |
| 1199 | fn every_failure_says_why_and_how_to_fix_it() { |
| 1200 | for code in [ |
| 1201 | Code::SignedOut, |
| 1202 | Code::NoWorkspace, |
| 1203 | Code::NotAllowed, |
| 1204 | Code::RegisterFailed, |
| 1205 | Code::NoDocker, |
| 1206 | Code::NoLauncher, |
| 1207 | Code::NoWsl, |
| 1208 | Code::WslMirrored, |
| 1209 | Code::NoVirtualization, |
| 1210 | Code::NoVfkit, |
| 1211 | Code::NoSysbox, |
| 1212 | Code::WaitingForImage, |
| 1213 | Code::ImageFailed, |
| 1214 | Code::SandboxUnavailable, |
| 1215 | Code::SandboxFailed, |
| 1216 | Code::SandboxTaken, |
| 1217 | Code::UpdateFailed, |
| 1218 | Code::UpdateRefused, |
| 1219 | Code::Offline, |
| 1220 | Code::Crashed, |
| 1221 | ] { |
| 1222 | let diagnosis = diagnose(code, &Facts { detail: Some("why".into()), ..facts() }); |
| 1223 | assert!(diagnosis.cause.is_some(), "{code:?} has no cause"); |
| 1224 | assert!(diagnosis.fix.is_some(), "{code:?} has no fix"); |
| 1225 | assert!(!diagnosis.actions.is_empty(), "{code:?} has no action"); |
| 1226 | assert!(matches!(diagnosis.tone, Tone::Warning | Tone::Problem), "{code:?}"); |
| 1227 | // No fix sends the person off to check their connection or report it |
| 1228 | // when that isn't what is wrong. |
| 1229 | let fix = diagnosis.fix.unwrap(); |
| 1230 | if !matches!(code, Code::Offline | Code::ImageFailed) { |
| 1231 | assert!(!fix.contains("check the connection") && !fix.contains("Check the connection"), "{code:?}: {fix}"); |
| 1232 | } |
| 1233 | } |
| 1234 | } |
| 1235 | |
| 1236 | #[test] |
| 1237 | fn the_words_name_the_workspace_the_machine_and_the_job() { |
| 1238 | let not_allowed = diagnose(Code::NotAllowed, &facts()); |
| 1239 | assert_eq!(not_allowed.cause.as_deref(), Some("You can't add runners to acme: only its owners can.")); |
| 1240 | assert_eq!(not_allowed.actions[0].id, "choose_workspace"); |
| 1241 | let busy = diagnose(Code::Busy, &Facts { job: Some("Build (acme/web)".into()), ..facts() }); |
| 1242 | assert_eq!(busy.title, "Running Build (acme/web)"); |
| 1243 | assert_eq!(busy.cause.as_deref(), Some("Running sandboxed for acme on build-box.")); |
| 1244 | let crashed = diagnose(Code::Crashed, &Facts { exit_code: Some(101), wait_seconds: Some(30), ..facts() }); |
| 1245 | assert_eq!(crashed.title, "Runner stopped (exit code 101)"); |
| 1246 | assert_eq!(crashed.fix.as_deref(), Some("It starts again by itself in 30 seconds. If it keeps stopping, its log says why; Restart starts it now.")); |
| 1247 | assert_eq!(crashed.actions.iter().map(|a| a.id).collect::<Vec<_>>(), ["restart", "view_log"]); |
| 1248 | assert_eq!(diagnose(Code::NoDocker, &facts()).actions[0].id, "open_docker"); |
| 1249 | assert_eq!(closed(&facts()).title, "g1t is closed on build-box"); |
| 1250 | assert_eq!(said_wait(300), "5 minutes"); |
| 1251 | assert_eq!(said_wait(45), "45 seconds"); |
| 1252 | } |
| 1253 | |
| 1254 | fn status(json: &str) -> Status { |
| 1255 | launcher::parse(json).unwrap() |
| 1256 | } |
| 1257 | |
| 1258 | const NO_WSL_PRO: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1", |
| 1259 | "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.", |
| 1260 | "capabilities":[ |
| 1261 | {"id":"linux","available":false,"state":"error","isolation":"nested-container", |
| 1262 | "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.", |
| 1263 | "fix":"Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows. It works on every edition of Windows 11, Home included."}, |
| 1264 | {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.2.1","job":null}]}"#; |
| 1265 | |
| 1266 | #[test] |
| 1267 | fn without_wsl_the_fix_is_the_command_to_copy_and_windows_jobs_still_show() { |
| 1268 | let status = status(NO_WSL_PRO); |
| 1269 | let (code, cause, _) = sandbox_trouble(&status).unwrap(); |
| 1270 | assert_eq!(code, Code::NoWsl); |
| 1271 | let diagnosis = diagnose(code, &Facts { detail: Some(cause), sandbox: Some(status), live: true, ..facts() }); |
| 1272 | assert_eq!(diagnosis.title, "Not running: WSL isn't installed"); |
| 1273 | assert_eq!(diagnosis.fix.as_deref(), Some("Run `wsl --install --no-distribution` in an admin terminal, then restart Windows. It works on every edition of Windows 11, Home included.")); |
| 1274 | assert_eq!(diagnosis.actions[0], COPY_WSL_INSTALL); |
| 1275 | let [linux, windows] = &diagnosis.capabilities[..] else { panic!("{:?}", diagnosis.capabilities) }; |
| 1276 | assert_eq!((linux.label.as_str(), linux.title.as_str(), linux.state), ("Linux jobs", "Not available", KindState::Unavailable)); |
| 1277 | assert_eq!(linux.headline.as_deref(), Some("WSL isn't installed")); |
| 1278 | assert_eq!(linux.fix, diagnosis.fix); |
| 1279 | assert_eq!(linux.actions[0].id, "copy_wsl_install"); |
| 1280 | assert_eq!((windows.label.as_str(), windows.title.as_str(), windows.tone), ("Windows jobs", "Waiting for jobs", Tone::Ok)); |
| 1281 | assert_eq!(windows.runner.as_deref(), Some("build-box-windows")); |
| 1282 | assert_eq!(windows.about, "Each job in a Windows Sandbox, discarded after"); |
| 1283 | // The Linux card says it all: the whole runner's line isn't shown as well. |
| 1284 | assert!(diagnosis.in_capabilities); |
| 1285 | // An older launcher can't stop one kind of job alone. |
| 1286 | assert!(!windows.can_toggle); |
| 1287 | } |
| 1288 | |
| 1289 | #[test] |
| 1290 | fn on_home_windows_jobs_are_unavailable_and_linux_jobs_still_run() { |
| 1291 | let status = status( |
| 1292 | r#"{"schema":1,"state":"running","backend":"wsl2","runner_version":"0.3.0","launcher_version":"0.3.0", |
| 1293 | "job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"}, |
| 1294 | "capabilities":[ |
| 1295 | {"id":"linux","available":true,"state":"running","runner_version":"0.3.0","isolation":"nested-container","turned_off":false,"job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"}}, |
| 1296 | {"id":"windows","available":false,"state":"absent","turned_off":false,"cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"On Windows 11 Pro…"}]}"#, |
| 1297 | ); |
| 1298 | assert_eq!(sandbox_trouble(&status), None); |
| 1299 | let busy = diagnose(Code::Busy, &Facts { job: status.job().map(launcher::Job::said), sandbox: Some(status), live: true, ..facts() }); |
| 1300 | assert_eq!(busy.title, "Running build (acme/web)"); |
| 1301 | assert!(busy.in_capabilities); |
| 1302 | let [linux, windows] = &busy.capabilities[..] else { panic!() }; |
| 1303 | assert_eq!((linux.title.as_str(), linux.state, linux.tone), ("Running a job", KindState::Busy, Tone::Working)); |
| 1304 | let job = linux.job.as_ref().unwrap(); |
| 1305 | assert_eq!((job.name.as_str(), job.repo.as_str(), job.link.as_deref()), ("build", "acme/web", Some("https://g1t.sh/acme/web/actions"))); |
| 1306 | assert!(linux.can_toggle && linux.enabled); |
| 1307 | assert_eq!((windows.title.as_str(), windows.tone, windows.state), ("Not available", Tone::Off, KindState::Unavailable)); |
| 1308 | assert_eq!(windows.headline.as_deref(), Some("Windows Sandbox is off")); |
| 1309 | assert_eq!(windows.cause.as_deref(), Some("Windows Sandbox is turned off, or this edition of Windows has none: Home doesn't.")); |
| 1310 | assert_eq!(windows.actions[0].id, "windows_features"); |
| 1311 | // Nothing to start or stop where it can't run. |
| 1312 | assert!(!windows.can_toggle); |
| 1313 | assert_eq!(windows.runner, None); |
| 1314 | } |
| 1315 | |
| 1316 | /// 0.3.0 was published without the sandbox's images: Linux jobs wait for |
| 1317 | /// them, plainly, while Windows jobs run. |
| 1318 | const NO_IMAGES: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.3.0", |
| 1319 | "error":"Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images", |
| 1320 | "capabilities":[ |
| 1321 | {"id":"linux","available":true,"state":"error","isolation":"nested-container","turned_off":false}, |
| 1322 | {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.3.0","turned_off":false}]}"#; |
| 1323 | |
| 1324 | #[test] |
| 1325 | fn a_release_without_images_is_waited_for_not_reported() { |
| 1326 | let status = status(NO_IMAGES); |
| 1327 | let (code, cause, _) = sandbox_trouble(&status).unwrap(); |
| 1328 | assert_eq!(code, Code::WaitingForImage); |
| 1329 | let whole = diagnose(code, &Facts { detail: Some(cause), ..facts() }); |
| 1330 | assert_eq!(whole.title, "Waiting for the sandbox's image"); |
| 1331 | assert_eq!(whole.cause.as_deref(), Some("g1t-runner 0.3.0 is published without the sandbox's images yet, so Linux jobs can't start.")); |
| 1332 | assert_eq!(whole.fix.as_deref(), Some("g1t checks again every few minutes and starts Linux jobs once they're out. There's nothing for you to do.")); |
| 1333 | assert_eq!(whole.actions, [CHECK_NOW]); |
| 1334 | assert!(!whole.fix.unwrap().contains("report")); |
| 1335 | // Windows jobs run: the runner waits for jobs, and the Linux card says what it waits for. |
| 1336 | let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status), live: true, ..facts() }); |
| 1337 | assert!(idle.in_capabilities); |
| 1338 | let [linux, windows] = &idle.capabilities[..] else { panic!() }; |
| 1339 | assert_eq!((linux.state, linux.title.as_str(), linux.tone), (KindState::Waiting, "Waiting for image", Tone::Warning)); |
| 1340 | assert_eq!(linux.headline.as_deref(), Some("Waiting for the sandbox's image")); |
| 1341 | assert_eq!(windows.state, KindState::Idle); |
| 1342 | let image = diagnose(Code::ImageFailed, &Facts { detail: Some("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it".into()), ..facts() }); |
| 1343 | assert_eq!(image.title, "Not running: the sandbox's image didn't check out"); |
| 1344 | let fetch = diagnose(Code::ImageFailed, &Facts { detail: Some("could not fetch https://g1t.sh/x: dns error".into()), wait_seconds: Some(60), ..facts() }); |
| 1345 | assert_eq!(fetch.cause.as_deref(), Some("The sandbox's image couldn't be downloaded: could not fetch https://g1t.sh/x: dns error")); |
| 1346 | assert_eq!(fetch.fix.as_deref(), Some("g1t tries again by itself in 60 seconds. Check that this computer can reach g1t.sh; Retry tries now.")); |
| 1347 | } |
| 1348 | |
| 1349 | #[test] |
| 1350 | fn a_kind_the_person_stopped_says_so_and_is_not_failing() { |
| 1351 | let status = status( |
| 1352 | r#"{"schema":1,"state":"running","backend":"wsl2","capabilities":[ |
| 1353 | {"id":"linux","available":true,"state":"running","turned_off":false}, |
| 1354 | {"id":"windows","available":true,"state":"stopped","turned_off":true}]}"#, |
| 1355 | ); |
| 1356 | let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status.clone()), live: true, off: vec!["windows".into()], ..facts() }); |
| 1357 | let [linux, windows] = &idle.capabilities[..] else { panic!() }; |
| 1358 | assert_eq!(linux.state, KindState::Idle); |
| 1359 | assert_eq!((windows.state, windows.tone, windows.enabled), (KindState::Stopped, Tone::Off, false)); |
| 1360 | assert_eq!(windows.cause.as_deref(), Some("You stopped Windows jobs on this computer. They stay stopped until you start them.")); |
| 1361 | assert!(windows.can_toggle && windows.actions.is_empty()); |
| 1362 | // Stopped by itself while Linux jobs run is a problem, with Retry. |
| 1363 | let died = launcher::parse(&serde_json::to_string(&serde_json::json!({"schema":1,"state":"running","capabilities":[ |
| 1364 | {"id":"linux","available":true,"state":"running","turned_off":false}, |
| 1365 | {"id":"windows","available":true,"state":"stopped","turned_off":false}]})).unwrap()).unwrap(); |
| 1366 | let idle = diagnose(Code::Idle, &Facts { sandbox: Some(died.clone()), live: true, ..facts() }); |
| 1367 | assert_eq!((idle.capabilities[1].state, idle.capabilities[1].actions[0].id), (KindState::Problem, "retry")); |
| 1368 | // With the whole runner stopped, it is only stopped. |
| 1369 | let stopped = diagnose(Code::Stopped, &Facts { sandbox: Some(died), live: false, held: true, ..facts() }); |
| 1370 | // Its own Start or Stop waits for the runner's: the line above the cards starts it. |
| 1371 | assert!(!stopped.capabilities[0].can_toggle); |
| 1372 | assert_eq!(stopped.capabilities[1].state, KindState::Stopped); |
| 1373 | assert!(!stopped.in_capabilities); |
| 1374 | } |
| 1375 | |
| 1376 | #[test] |
| 1377 | fn a_download_shows_how_far_it_is() { |
| 1378 | let status = status(r#"{"schema":1,"state":"starting","capabilities":[{"id":"linux","available":true,"state":"starting","turned_off":false},{"id":"windows","available":true,"state":"absent","turned_off":false}]}"#); |
| 1379 | let facts = Facts { sandbox: Some(status), live: true, starting: vec!["linux".into(), "windows".into()], download: Some(launcher::Download { key: "wsl-x64".into(), percent: 40 }), ..facts() }; |
| 1380 | let starting = diagnose(Code::Starting, &facts); |
| 1381 | assert!(starting.in_capabilities); |
| 1382 | let [linux, windows] = &starting.capabilities[..] else { panic!() }; |
| 1383 | assert_eq!((linux.state, linux.title.as_str(), linux.progress), (KindState::Downloading, "Downloading image", Some(40))); |
| 1384 | assert_eq!((windows.state, windows.progress), (KindState::Starting, None)); |
| 1385 | } |
| 1386 | |
| 1387 | #[test] |
| 1388 | fn each_launcher_trouble_has_its_words() { |
| 1389 | let trouble = |linux_available: bool, text: &str| { |
| 1390 | let json = serde_json::json!({ |
| 1391 | "schema": 1, "state": "error", "error": text, |
| 1392 | "capabilities": [{"id": "linux", "available": linux_available, "state": "error", "cause": if linux_available { None } else { Some(text) }, "fix": "the launcher's fix"}] |
| 1393 | }); |
| 1394 | sandbox_trouble(&status(&json.to_string())).unwrap() |
| 1395 | }; |
| 1396 | assert_eq!(trouble(true, "WSL could not import the sandbox: Please enable the Virtual Machine Platform Windows feature and ensure virtualization is enabled in the BIOS.").0, Code::NoVirtualization); |
| 1397 | assert_eq!(trouble(false, "vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.").0, Code::NoVfkit); |
| 1398 | assert_eq!(trouble(false, "This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.").0, Code::NoSysbox); |
| 1399 | assert_eq!(trouble(false, "WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.").0, Code::WslMirrored); |
| 1400 | assert_eq!(trouble(true, "the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it").0, Code::ImageFailed); |
| 1401 | assert_eq!(trouble(true, "release 0.3.0 has no sandbox images").0, Code::WaitingForImage); |
| 1402 | // Unknown words: shown as the launcher wrote them, its fix with them. |
| 1403 | let (code, cause, fix) = trouble(false, "This user may not use /dev/kvm."); |
| 1404 | assert_eq!(code, Code::SandboxUnavailable); |
| 1405 | let unavailable = diagnose(code, &Facts { detail: Some(cause), fix, ..facts() }); |
| 1406 | assert_eq!(unavailable.cause.as_deref(), Some("This user may not use /dev/kvm.")); |
| 1407 | assert_eq!(unavailable.fix.as_deref(), Some("the launcher's fix")); |
| 1408 | let (code, cause, _) = trouble(true, "the sandbox did not start within 600 seconds"); |
| 1409 | assert_eq!(code, Code::SandboxFailed); |
| 1410 | let failed = diagnose(code, &Facts { detail: Some(cause), wait_seconds: Some(60), ..facts() }); |
| 1411 | assert_eq!(failed.cause.as_deref(), Some("the sandbox did not start within 600 seconds")); |
| 1412 | assert_eq!(failed.fix.as_deref(), Some("It starts again by itself in 60 seconds. If it keeps stopping, its log says why; Retry starts it now.")); |
| 1413 | assert_eq!(diagnose(Code::NoVirtualization, &Facts { platform: "macos".into(), ..facts() }).actions[0].id, "retry"); |
| 1414 | assert_eq!(diagnose(Code::NoVfkit, &facts()).actions[0].id, "copy_vfkit_install"); |
| 1415 | } |
| 1416 | |
| 1417 | #[test] |
| 1418 | fn when_g1t_last_looked_for_a_runner_is_said_plainly() { |
| 1419 | let ago = |seconds: u64| checked(&Update { checked_ago: Some(seconds), ..Update::default() }); |
| 1420 | assert_eq!(checked(&Update::default()), "Not checked yet"); |
| 1421 | assert_eq!(checked(&Update { checking: true, checked_ago: Some(30), ..Update::default() }), "Checking for updates…"); |
| 1422 | assert_eq!(ago(20), "Checked just now"); |
| 1423 | assert_eq!(ago(12 * 60 + 5), "Checked 12 min ago"); |
| 1424 | assert_eq!(ago(3600), "Checked 1 hour ago"); |
| 1425 | assert_eq!(ago(5 * 3600), "Checked 5 hours ago"); |
| 1426 | assert_eq!(ago(2 * 86_400), "Checked 2 days ago"); |
| 1427 | } |
| 1428 | |
| 1429 | /// 0.3.2 is downloaded: Linux jobs take its image after the job they |
| 1430 | /// are running, Windows jobs, idle, take its program now. |
| 1431 | #[test] |
| 1432 | fn a_waiting_update_is_said_on_each_kind_of_jobs_card() { |
| 1433 | let status = status( |
| 1434 | r#"{"schema":1,"state":"running","backend":"wsl2","image_version":"0.3.1","capabilities":[ |
| 1435 | {"id":"linux","available":true,"state":"running","runner_version":"0.3.1","turned_off":false,"job":{"kind":"workflow","id":"j","name":"build","repo":"acme/web"}}, |
| 1436 | {"id":"windows","available":true,"state":"running","runner_version":"0.3.1","turned_off":false}]}"#, |
| 1437 | ); |
| 1438 | let update = Update { |
| 1439 | installed: Some("0.3.1".into()), |
| 1440 | checked_at: Some("2026-10-12T12:00:00Z".into()), |
| 1441 | checked_ago: Some(12 * 60), |
| 1442 | ready: Some("0.3.2".into()), |
| 1443 | waiting: vec!["linux".into(), "windows".into()], |
| 1444 | ..Update::default() |
| 1445 | }; |
| 1446 | let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status.clone()), live: true, update: update.clone(), ..facts() }); |
| 1447 | let [linux, windows] = &busy.capabilities[..] else { panic!() }; |
| 1448 | assert_eq!(linux.version.as_deref(), Some("0.3.1")); |
| 1449 | assert_eq!(linux.update.as_deref(), Some("0.3.2 ready, installs after this job")); |
| 1450 | assert_eq!(windows.update.as_deref(), Some("0.3.2 ready, installs in a moment")); |
| 1451 | let line = busy.update.unwrap(); |
| 1452 | assert_eq!((line.checked.as_str(), line.ready.as_deref(), line.checked_at.as_deref()), ("Checked 12 min ago", Some("0.3.2"), Some("2026-10-12T12:00:00Z"))); |
| 1453 | // The cards say it, each for its own kind: nothing is said for the whole runner. |
| 1454 | assert_eq!(line.note, None); |
| 1455 | assert_eq!((line.check.id, line.check.label), ("check_updates", "Check for runner updates")); |
| 1456 | // Windows jobs stop while the program is replaced: installing, then nothing waits. |
| 1457 | let installing = Update { installing: vec!["windows".into()], ..update.clone() }; |
| 1458 | let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status.clone()), live: true, update: installing, ..facts() }); |
| 1459 | assert_eq!(busy.capabilities[1].update.as_deref(), Some("Installing 0.3.2")); |
| 1460 | let done = Update { waiting: vec!["linux".into()], ..update.clone() }; |
| 1461 | let busy = diagnose(Code::Busy, &Facts { sandbox: Some(status), live: true, update: done, ..facts() }); |
| 1462 | assert_eq!(busy.capabilities[1].update, None); |
| 1463 | // In Docker or on this computer there are no cards: the whole runner says it. |
| 1464 | let docker = Update { waiting: vec!["runner".into()], ..update }; |
| 1465 | let busy = diagnose(Code::Busy, &Facts { backend: "in Docker", job: Some("build (acme/web)".into()), update: docker.clone(), ..facts() }); |
| 1466 | assert_eq!(busy.update.unwrap().note.as_deref(), Some("0.3.2 ready, installs after this job")); |
| 1467 | let idle = diagnose(Code::Idle, &Facts { backend: "in Docker", update: docker, ..facts() }); |
| 1468 | assert_eq!(idle.update.unwrap().note.as_deref(), Some("0.3.2 ready, installs in a moment")); |
| 1469 | // What is said once g1t is closed knows nothing of updates. |
| 1470 | assert_eq!(closed(&facts()).update, None); |
| 1471 | } |
| 1472 | |
| 1473 | #[test] |
| 1474 | fn a_release_not_signed_by_g1t_is_refused_and_said_so() { |
| 1475 | let detail = Some("the release's signature isn't g1t's".to_owned()); |
| 1476 | let running = diagnose(Code::UpdateRefused, &Facts { detail: detail.clone(), update: Update { installed: Some("0.3.1".into()), ..Update::default() }, ..facts() }); |
| 1477 | assert_eq!(running.title, "Update refused: not signed by g1t"); |
| 1478 | assert_eq!( |
| 1479 | running.cause.as_deref(), |
| 1480 | Some("The newest g1t-runner release from g1t.sh isn't signed with g1t's release key (the release's signature isn't g1t's), so g1t didn't install it.") |
| 1481 | ); |
| 1482 | assert!(running.fix.as_deref().unwrap().starts_with("The runner keeps running g1t-runner 0.3.1, and g1t checks again within the hour.")); |
| 1483 | assert_eq!(running.actions, [CHECK_AGAIN, REPORT]); |
| 1484 | assert_eq!(running.tone, Tone::Warning); |
| 1485 | // With no runner yet, nothing runs until a signed one downloads. |
| 1486 | let nothing = diagnose(Code::UpdateRefused, &Facts { detail, ..facts() }); |
| 1487 | assert_eq!(nothing.title, "Not running: the runner's release isn't signed by g1t"); |
| 1488 | assert_eq!((nothing.tone, nothing.actions[0].id), (Tone::Problem, "retry")); |
| 1489 | assert!(!nothing.in_capabilities); |
| 1490 | } |
| 1491 | |
| 1492 | #[test] |
| 1493 | fn the_title_bars_chip_says_the_runner_in_a_few_words() { |
| 1494 | let said = |code: Code, facts: &Facts| { |
| 1495 | let chip = chip(&diagnose(code, facts)); |
| 1496 | (chip.tone, chip.label) |
| 1497 | }; |
| 1498 | assert_eq!(said(Code::Idle, &facts()), (Tone::Ok, "Runner running".into())); |
| 1499 | assert_eq!(said(Code::Busy, &Facts { job: Some("build (acme/web)".into()), ..facts() }), (Tone::Working, "Running a job".into())); |
| 1500 | assert_eq!(said(Code::Starting, &facts()), (Tone::Working, "Runner starting".into())); |
| 1501 | assert_eq!(said(Code::Stopped, &facts()), (Tone::Off, "Runner stopped".into())); |
| 1502 | assert_eq!(said(Code::Off, &facts()), (Tone::Off, "Runner off".into())); |
| 1503 | assert_eq!(said(Code::NoWsl, &facts()), (Tone::Problem, "Runner needs you".into())); |
| 1504 | assert_eq!(said(Code::UpdateFailed, &Facts { detail: Some("x".into()), ..facts() }).1, "Runner running"); |
| 1505 | // Both kinds of job busy at once. |
| 1506 | let both = status( |
| 1507 | r#"{"schema":1,"state":"running","capabilities":[ |
| 1508 | {"id":"linux","available":true,"state":"running","turned_off":false,"job":{"kind":"workflow","id":"a","name":"build","repo":"acme/web"}}, |
| 1509 | {"id":"windows","available":true,"state":"running","turned_off":false,"job":{"kind":"workflow","id":"b","name":"installer","repo":"acme/app"}}]}"#, |
| 1510 | ); |
| 1511 | assert_eq!(said(Code::Busy, &Facts { sandbox: Some(both), live: true, ..facts() }), (Tone::Working, "Running 2 jobs".into())); |
| 1512 | // Linux jobs run while Windows jobs can't start: running, and wanting a look. |
| 1513 | let one_failing = status(NO_WSL_PRO); |
| 1514 | assert_eq!(said(Code::Idle, &Facts { sandbox: Some(one_failing), live: true, ..facts() }), (Tone::Warning, "Runner running".into())); |
| 1515 | } |
| 1516 | |
| 1517 | #[test] |
| 1518 | fn a_diagnosis_without_the_sandbox_lists_no_capabilities() { |
| 1519 | let json = serde_json::to_value(diagnose(Code::Idle, &Facts { backend: "in Docker", ..facts() })).unwrap(); |
| 1520 | assert!(json.get("capabilities").is_none()); |
| 1521 | assert!(json.get("in_capabilities").is_none()); |
| 1522 | let sandboxed = serde_json::to_value(diagnose(Code::Idle, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() })).unwrap(); |
| 1523 | assert_eq!(sandboxed["capabilities"][1]["id"], "windows"); |
| 1524 | assert_eq!(sandboxed["capabilities"][1]["state"], "idle"); |
| 1525 | assert_eq!(sandboxed["in_capabilities"], true); |
| 1526 | // What affects everything is said once, above the cards. |
| 1527 | let signed_out = diagnose(Code::SignedOut, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() }); |
| 1528 | assert!(!signed_out.in_capabilities); |
| 1529 | } |
| 1530 | |
| 1531 | // --- What this computer lacks, by name (the launcher's `needs`) ------------------ |
| 1532 | |
| 1533 | /// A launcher's status with each kind of job's needs: `None` for a kind |
| 1534 | /// that is running, waiting for jobs. |
| 1535 | fn needing(linux: Option<&[&str]>, native: Option<(&str, Option<&[&str]>)>) -> Status { |
| 1536 | let kind = |id: &str, needs: Option<&[&str]>| match needs { |
| 1537 | None => format!(r#"{{"id":"{id}","available":true,"state":"running","runner_version":"0.3.5","turned_off":false,"needs":[]}}"#), |
| 1538 | // What the launcher says of macOS jobs, which need nothing: they aren't released. |
| 1539 | Some([]) => format!(r#"{{"id":"{id}","available":false,"state":"absent","turned_off":false,"cause":"Sandboxed macOS jobs are not released yet.","fix":"Run macOS jobs on a runner registered with --no-docker for now.","needs":[]}}"#), |
| 1540 | Some(needs) => format!( |
| 1541 | r#"{{"id":"{id}","available":false,"state":"{}","turned_off":false,"cause":"In the launcher's words.","fix":"Its fix.","needs":{}}}"#, |
| 1542 | if id == "linux" { "error" } else { "absent" }, |
| 1543 | serde_json::to_string(needs).unwrap() |
| 1544 | ), |
| 1545 | }; |
| 1546 | let mut kinds = vec![kind("linux", linux)]; |
| 1547 | if let Some((id, needs)) = native { |
| 1548 | kinds.push(kind(id, needs)); |
| 1549 | } |
| 1550 | let state = if linux.is_none() { "running" } else { "error" }; |
| 1551 | status(&format!(r#"{{"schema":1,"state":"{state}","backend":"wsl2","launcher_version":"0.3.5","capabilities":[{}]}}"#, kinds.join(","))) |
| 1552 | } |
| 1553 | |
| 1554 | /// What the whole runner and each card say, as the app would show it: the code `shown` picks. |
| 1555 | fn shown_for(status: Status, facts: Facts) -> Diagnosis { |
| 1556 | let code = if status.running() { Code::Idle } else if status.linux_needs().is_empty() { Code::SandboxFailed } else { Code::NeedsSetup }; |
| 1557 | // As the app fills it in: the needs of each kind that is on. |
| 1558 | let setup = SetUp { needs: super::super::setup::needs(&status, |kind| !facts.off.iter().any(|off| off == kind)), ..facts.setup.clone() }; |
| 1559 | diagnose(code, &Facts { sandbox: Some(status), live: true, setup, ..facts }) |
| 1560 | } |
| 1561 | |
| 1562 | fn ids(actions: &[Action]) -> Vec<&'static str> { |
| 1563 | actions.iter().map(|a| a.id).collect() |
| 1564 | } |
| 1565 | |
| 1566 | /// Writes each scenario's diagnosis to `G1T_SCREENS_OUT`, when it is set, for the screens. |
| 1567 | fn screen(name: &str, diagnosis: &Diagnosis) { |
| 1568 | if let Some(dir) = std::env::var_os("G1T_SCREENS_OUT") { |
| 1569 | let dir = std::path::PathBuf::from(dir); |
| 1570 | std::fs::create_dir_all(&dir).unwrap(); |
| 1571 | let shown = serde_json::json!({ "diagnosis": diagnosis, "chip": chip(diagnosis) }); |
| 1572 | std::fs::write(dir.join(format!("{name}.json")), serde_json::to_vec_pretty(&shown).unwrap()).unwrap(); |
| 1573 | } |
| 1574 | } |
| 1575 | |
| 1576 | #[test] |
| 1577 | fn a_clean_windows_home_is_set_up_with_one_button_and_says_windows_jobs_plainly() { |
| 1578 | let home = shown_for(needing(Some(&["vm_platform", "wsl"]), Some(("windows", Some(&["windows_edition"])))), facts()); |
| 1579 | screen("windows-home-clean", &home); |
| 1580 | assert_eq!(home.code, Code::NeedsSetup); |
| 1581 | assert_eq!(home.title, "Not running: WSL isn't installed"); |
| 1582 | assert!(home.in_capabilities); |
| 1583 | let [linux, windows] = &home.capabilities[..] else { panic!() }; |
| 1584 | assert_eq!((linux.title.as_str(), linux.tone, linux.state), ("Needs setting up", Tone::Warning, KindState::Unavailable)); |
| 1585 | assert_eq!(linux.headline.as_deref(), Some("WSL isn't installed")); |
| 1586 | assert_eq!(linux.cause.as_deref(), Some("Linux jobs run in a small Linux VM of their own under WSL 2, which needs Virtual Machine Platform turned on and WSL installed.")); |
| 1587 | assert_eq!(linux.fix.as_deref(), Some("Set up turns on Virtual Machine Platform and installs WSL in one go. Windows asks you to allow it, and to restart after.")); |
| 1588 | assert_eq!(ids(&linux.actions), ["set_up", "setup_help"]); |
| 1589 | assert!(SET_UP.waits && SET_UP.confirm.is_none(), "Windows' own prompt asks"); |
| 1590 | // Home: no dead end, and no button that can't work. |
| 1591 | assert_eq!((windows.title.as_str(), windows.tone), ("Not available", Tone::Off)); |
| 1592 | assert_eq!(windows.headline.as_deref(), Some("Not in Windows Home")); |
| 1593 | assert_eq!(windows.fix.as_deref(), Some("Linux jobs run here all the same. Windows jobs need Windows 11 Pro, Enterprise or Education.")); |
| 1594 | assert_eq!(ids(&windows.actions), ["windows_activation"]); |
| 1595 | // The chip asks for the person. |
| 1596 | assert_eq!(chip(&home).label, "Runner needs you"); |
| 1597 | } |
| 1598 | |
| 1599 | #[test] |
| 1600 | fn pro_sets_up_both_kinds_and_each_says_its_own() { |
| 1601 | let pro = shown_for(needing(Some(&["vm_platform", "wsl"]), Some(("windows", Some(&["windows_sandbox"])))), facts()); |
| 1602 | screen("windows-pro-clean", &pro); |
| 1603 | let [linux, windows] = &pro.capabilities[..] else { panic!() }; |
| 1604 | assert_eq!(ids(&linux.actions), ["set_up", "setup_help"]); |
| 1605 | assert_eq!(windows.headline.as_deref(), Some("Windows Sandbox is off")); |
| 1606 | // One press sets up both kinds, and each card says all it does. |
| 1607 | let both = "Set up turns on Virtual Machine Platform and Windows Sandbox and installs WSL in one go. Windows asks you to allow it, and to restart after."; |
| 1608 | assert_eq!((linux.fix.as_deref(), windows.fix.as_deref()), (Some(both), Some(both))); |
| 1609 | // Windows jobs stopped by the person: Set up leaves their feature alone, and says so. |
| 1610 | let linux_only = shown_for( |
| 1611 | needing(Some(&["vm_platform", "wsl"]), Some(("windows", Some(&["windows_sandbox"])))), |
| 1612 | Facts { setup: SetUp { needs: vec!["vm_platform".into(), "wsl".into()], ..SetUp::default() }, off: vec!["windows".into()], ..facts() }, |
| 1613 | ); |
| 1614 | assert_eq!(linux_only.capabilities[0].fix.as_deref(), Some("Set up turns on Virtual Machine Platform and installs WSL in one go. Windows asks you to allow it, and to restart after.")); |
| 1615 | // Linux jobs run while Windows Sandbox is off: waiting for jobs, and one card wants a look. |
| 1616 | let linux_runs = shown_for(needing(None, Some(("windows", Some(&["windows_sandbox"])))), facts()); |
| 1617 | screen("windows-pro-sandbox-off", &linux_runs); |
| 1618 | assert_eq!(linux_runs.title, "Waiting for jobs"); |
| 1619 | assert_eq!(linux_runs.capabilities[0].title, "Waiting for jobs"); |
| 1620 | assert_eq!(ids(&linux_runs.capabilities[1].actions), ["set_up", "setup_help"]); |
| 1621 | assert_eq!(chip(&linux_runs), Chip { tone: Tone::Warning, label: "Runner running".into() }); |
| 1622 | // Windows jobs run while Linux jobs need WSL: each kind on its own. |
| 1623 | let windows_runs = shown_for(needing(Some(&["wsl"]), Some(("windows", None))), facts()); |
| 1624 | screen("windows-pro-wsl-missing", &windows_runs); |
| 1625 | assert_eq!(windows_runs.code, Code::Idle); |
| 1626 | assert_eq!(chip(&windows_runs), Chip { tone: Tone::Warning, label: "Runner running".into() }); |
| 1627 | assert_eq!(windows_runs.capabilities[1].title, "Waiting for jobs"); |
| 1628 | assert_eq!(windows_runs.capabilities[0].fix.as_deref(), Some("Set up installs WSL in one go. Windows asks you to allow it.")); |
| 1629 | // An older WSL is updated, not installed again. |
| 1630 | let old = shown_for(needing(Some(&["wsl_update"]), None), facts()); |
| 1631 | assert_eq!(old.capabilities[0].headline.as_deref(), Some("WSL needs an update")); |
| 1632 | assert_eq!(old.capabilities[0].fix.as_deref(), Some("Set up updates WSL in one go. Windows asks you to allow it.")); |
| 1633 | } |
| 1634 | |
| 1635 | #[test] |
| 1636 | fn set_up_shows_while_it_works_then_a_restart_now_or_later() { |
| 1637 | let clean = needing(Some(&["vm_platform", "wsl"]), Some(("windows", Some(&["windows_sandbox"])))); |
| 1638 | let working = SetUp { running: Some("turns on Virtual Machine Platform and Windows Sandbox and installs WSL".into()), ..SetUp::default() }; |
| 1639 | let setting_up = shown_for(clean.clone(), Facts { setup: working, ..facts() }); |
| 1640 | screen("windows-setting-up", &setting_up); |
| 1641 | assert_eq!(setting_up.title, "Not running: setting up"); |
| 1642 | assert_eq!(setting_up.tone, Tone::Working); |
| 1643 | assert_eq!(chip(&setting_up), Chip { tone: Tone::Working, label: "Runner setting up".into() }); |
| 1644 | for kind in &setting_up.capabilities { |
| 1645 | assert_eq!((kind.title.as_str(), kind.state, kind.tone), ("Setting up", KindState::Starting, Tone::Working)); |
| 1646 | assert!(kind.actions.is_empty()); |
| 1647 | assert_eq!(kind.cause.as_deref(), Some("g1t turns on Virtual Machine Platform and Windows Sandbox and installs WSL. Windows asks you to allow it first, and it takes a few minutes.")); |
| 1648 | } |
| 1649 | let restart = shown_for(needing(Some(&["restart"]), Some(("windows", Some(&["restart"])))), facts()); |
| 1650 | screen("windows-restart", &restart); |
| 1651 | assert_eq!(restart.title, "Not running: restart to finish setting up"); |
| 1652 | let linux = &restart.capabilities[0]; |
| 1653 | assert_eq!((linux.title.as_str(), linux.tone), ("Restart needed", Tone::Warning)); |
| 1654 | assert_eq!(ids(&linux.actions), ["restart_windows", "restart_later"]); |
| 1655 | // Windows' own dialog asks, not a page's, which a page could skip. |
| 1656 | assert!(RESTART_WINDOWS.confirm.is_none() && !RESTART_WINDOWS.waits); |
| 1657 | assert!(super::super::setup::RESTART_QUESTION.starts_with("Restart Windows now")); |
| 1658 | assert_eq!(linux.fix.as_deref(), Some("Restart now, or later: g1t opens again after the restart and carries on by itself.")); |
| 1659 | let later = shown_for(needing(Some(&["restart"]), None), Facts { setup: SetUp { later: true, ..SetUp::default() }, ..facts() }); |
| 1660 | screen("windows-restart-later", &later); |
| 1661 | assert_eq!(ids(&later.capabilities[0].actions), ["restart_windows"]); |
| 1662 | // Said No to Windows' prompt: nothing changed, and Set up again. |
| 1663 | let declined = SetUp { failed: Some("you said No to Windows' prompt, so nothing changed.".into()), ..SetUp::default() }; |
| 1664 | let again = shown_for(clean, Facts { setup: declined, ..facts() }); |
| 1665 | screen("windows-set-up-declined", &again); |
| 1666 | assert_eq!(again.capabilities[0].cause.as_deref(), Some("Set up didn't finish: you said No to Windows' prompt, so nothing changed.")); |
| 1667 | assert_eq!(ids(&again.capabilities[0].actions), ["set_up", "view_log"]); |
| 1668 | } |
| 1669 | |
| 1670 | #[test] |
| 1671 | fn what_only_the_person_can_do_is_said_step_by_step() { |
| 1672 | let firmware = shown_for(needing(Some(&["virtualization"]), Some(("windows", Some(&["virtualization"])))), facts()); |
| 1673 | screen("windows-virtualization-off", &firmware); |
| 1674 | assert_eq!(firmware.title, "Not running: virtualization is off"); |
| 1675 | for kind in &firmware.capabilities { |
| 1676 | assert_eq!(kind.headline.as_deref(), Some("Virtualization is off")); |
| 1677 | assert!(kind.fix.as_deref().is_some_and(|fix| fix.contains("BIOS or UEFI") && fix.contains("SVM Mode"))); |
| 1678 | assert_eq!(ids(&kind.actions), ["firmware_help", "retry"]); |
| 1679 | } |
| 1680 | let nested = shown_for(needing(Some(&["nested_virtualization"]), Some(("windows", None))), facts()); |
| 1681 | screen("windows-in-a-vm", &nested); |
| 1682 | assert_eq!(nested.capabilities[0].headline.as_deref(), Some("This VM can't run the sandbox")); |
| 1683 | assert!(nested.capabilities[0].fix.as_deref().is_some_and(|fix| fix.contains("ExposeVirtualizationExtensions"))); |
| 1684 | let old = shown_for(needing(Some(&["windows_update"]), Some(("windows", Some(&["windows_update"])))), facts()); |
| 1685 | screen("windows-too-old", &old); |
| 1686 | assert_eq!(ids(&old.capabilities[0].actions), ["windows_update", "retry"]); |
| 1687 | assert!(old.capabilities[0].cause.as_deref().is_some_and(|c| c.contains("version 2004"))); |
| 1688 | assert!(old.capabilities[1].cause.as_deref().is_some_and(|c| c.contains("24H2"))); |
| 1689 | } |
| 1690 | |
| 1691 | #[test] |
| 1692 | fn linux_and_macos_say_the_command_that_fixes_it() { |
| 1693 | let linux = Facts { platform: "linux".into(), qemu_install: Some("sudo apt install qemu-system-x86".into()), ..facts() }; |
| 1694 | let group = shown_for(needing(Some(&["kvm_group"]), None), linux.clone()); |
| 1695 | screen("linux-kvm-group", &group); |
| 1696 | assert_eq!(group.title, "Not running: no access to KVM"); |
| 1697 | assert_eq!(group.capabilities[0].fix.as_deref(), Some("Run `sudo usermod -aG kvm $USER` (Copy command), then log out and back in.")); |
| 1698 | assert_eq!(ids(&group.capabilities[0].actions), ["copy_kvm_group", "retry"]); |
| 1699 | let bare = shown_for(needing(Some(&["kvm_module", "qemu"]), None), linux.clone()); |
| 1700 | screen("linux-no-kvm", &bare); |
| 1701 | assert_eq!(bare.title, "Not running: KVM isn't loaded"); |
| 1702 | assert_eq!(ids(&bare.capabilities[0].actions), ["copy_kvm_load", "retry"]); |
| 1703 | let qemu = shown_for(needing(Some(&["qemu"]), None), linux.clone()); |
| 1704 | assert_eq!(qemu.capabilities[0].fix.as_deref(), Some("Install it with `sudo apt install qemu-system-x86` (Copy command), then Check now. Or install Sysbox 0.7 or later instead.")); |
| 1705 | let unknown_distro = shown_for(needing(Some(&["qemu"]), None), Facts { qemu_install: None, ..linux.clone() }); |
| 1706 | assert_eq!(ids(&unknown_distro.capabilities[0].actions), ["retry", "install_sysbox"]); |
| 1707 | let firmware = shown_for(needing(Some(&["virtualization"]), None), linux); |
| 1708 | assert!(firmware.capabilities[0].cause.as_deref().is_some_and(|c| c.contains("or has none"))); |
| 1709 | let mac = Facts { platform: "macos".into(), ..facts() }; |
| 1710 | let no_brew = shown_for(needing(Some(&["homebrew"]), Some(("macos", Some(&[])))), mac.clone()); |
| 1711 | screen("macos-no-homebrew", &no_brew); |
| 1712 | assert_eq!(no_brew.title, "Not running: vfkit isn't installed"); |
| 1713 | assert_eq!(ids(&no_brew.capabilities[0].actions), ["get_homebrew", "copy_vfkit_install"]); |
| 1714 | let old = shown_for(needing(Some(&["macos_update"]), None), mac); |
| 1715 | assert_eq!(ids(&old.capabilities[0].actions), ["software_update"]); |
| 1716 | } |
| 1717 | |
| 1718 | #[test] |
| 1719 | fn every_need_has_a_way_forward() { |
| 1720 | let needs = [ |
| 1721 | "restart", "windows_update", "virtualization", "nested_virtualization", "vm_platform", "wsl", "wsl_update", "windows_edition", "windows_sandbox", "kvm_module", "kvm_group", "qemu", "macos_update", "homebrew", |
| 1722 | "vfkit", |
| 1723 | ]; |
| 1724 | for need in needs { |
| 1725 | for kind in ["linux", "windows"] { |
| 1726 | let words = need_words(&[need.to_owned()], kind, &facts()).unwrap_or_else(|| panic!("{need} has no words")); |
| 1727 | assert!(!words.actions.is_empty(), "{need} on {kind} is a dead end"); |
| 1728 | assert!(words.fix.is_some_and(|fix| fix.ends_with('.')), "{need}"); |
| 1729 | assert!(words.cause.ends_with('.'), "{need}"); |
| 1730 | } |
| 1731 | } |
| 1732 | // A need from a newer launcher this app doesn't know: the launcher's own words. |
| 1733 | let newer = shown_for(needing(Some(&["something_new"]), None), facts()); |
| 1734 | assert_eq!(newer.capabilities[0].cause.as_deref(), Some("In the launcher's words.")); |
| 1735 | // From a launcher before needs: as before, by its words. |
| 1736 | let before = diagnose(Code::NoWsl, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() }); |
| 1737 | assert_eq!(ids(&before.capabilities[0].actions), ["copy_wsl_install", "learn_more"]); |
| 1738 | assert_eq!(lowered("WSL isn't installed"), "WSL isn't installed"); |
| 1739 | assert_eq!(lowered("No access to KVM"), "no access to KVM"); |
| 1740 | } |
| 1741 | } |