The desktop app runs jobs sandboxed on Windows, macOS and Linux. Its Sandboxed choice, the default, is now g1t-runner's sandbox launcher on every computer, where on Windows and macOS it said a sandbox isn't available on this computer yet and ran nothing. A few seconds after g1t opens, the app runs `g1t-runner sandbox up --json` with its own copy of the runner and its state in the app's data folder, its URL, the computer's name and the `desktop` label (the launcher adds `-linux` and `-windows` and the OS labels itself), and the registration token from the site the first time only: the launcher keeps the registration after that, across updates too, and a workspace's sandbox is remembered (sandbox-registration.json), so choosing Sandboxed again after another way of running jobs needs no new token. Linux jobs run in a Linux VM of the runner's own (WSL 2 on Windows, vfkit on macOS, Sysbox or QEMU with KVM on Linux), and on Windows 11 Pro and up Windows jobs run each in a Windows Sandbox. While it runs, the app asks `sandbox status --json` every eight seconds, on a thread of its own, for the tray, settings and the site. Quitting or Stop runs `sandbox down`; choosing another way of running jobs waits for the job and runs `down --drain`; moving to another workspace removes the sandbox (`down --delete`) and makes it again. A new runner release is put in place while nothing runs, and the sandbox then takes the release's image with `sandbox update`; every six hours, `update` also runs on an idle sandbox, which does nothing when its image is the newest.
The launcher's status is read in one place (runner/launcher.rs): typed, schema 1 checked, any field it adds ignored, and found after the log lines `up` prints before it. Why the sandbox can't run, in the launcher's sentences, is said in the catalogue's own words with a fix and its buttons (runner/diagnosis.rs): WSL not installed, with Copy command for `wsl --install --no-distribution` in an admin terminal; WSL's mirrored networking; virtualization off, with Open Windows features; vfkit missing, with Copy command for `brew install vfkit`; no Sysbox or KVM on Linux; the sandbox's image not downloaded or not the release's; and anything else in the launcher's words, its fix with it. A token g1t refused, a sandbox with no registration, or a runner g1t no longer knows registers again by itself. Each kind of job has a line of its own, Linux jobs and Windows jobs, in settings and in the Desktops section of the site's Runners page: on Windows Home, Windows jobs are Not available, Windows Sandbox is off or the edition has none, and Linux jobs still run. The report's diagnosis carries these lines (`capabilities`, in the contracts and kept by report_desktop with the diagnosis it belongs to). A sandbox someone made by hand with `g1t-runner sandbox up` is left alone: the app says this computer has another sandbox and how to remove it. A `g1t-runner` from before the launcher is replaced by a newer release, or said plainly. Nothing ever falls back to Docker or to running jobs directly on the computer. The Linux container the app ran itself (runner/container.rs) is gone: the launcher runs it under Sysbox there now. The desktop guide says how the sandbox runs, what each computer needs for it, the commands the app runs and when, and every state with its fix; What's not there yet no longer lists the sandbox.
| 261 | 261 | .diagnosis-text .buttons:empty { | |
| 262 | 262 | display: none; | |
| 263 | 263 | } | |
| 264 | + | /* With the sandbox, each kind of job it runs: Linux jobs, Windows jobs. */ | |
| 265 | + | .capabilities { | |
| 266 | + | display: flex; | |
| 267 | + | flex-direction: column; | |
| 268 | + | gap: 0.5rem; | |
| 269 | + | margin: 0.5rem 0 0; | |
| 270 | + | padding: 0.5rem 0 0; | |
| 271 | + | border-top: 1px solid var(--line); | |
| 272 | + | list-style: none; | |
| 273 | + | } | |
| 274 | + | .capabilities[hidden] { | |
| 275 | + | display: none; | |
| 276 | + | } | |
| 277 | + | .capabilities li { | |
| 278 | + | display: flex; | |
| 279 | + | align-items: flex-start; | |
| 280 | + | gap: 0.5rem; | |
| 281 | + | } | |
| 282 | + | .capabilities .dot { | |
| 283 | + | margin-top: 0.3rem; | |
| 284 | + | } | |
| 264 | 285 | .dot { | |
| 265 | 286 | flex: none; | |
| 266 | 287 | width: 8px; |
| 61 | 61 | <span class="muted" id="runner-cause"></span> | |
| 62 | 62 | <span id="runner-fix"></span> | |
| 63 | 63 | <span class="buttons" id="runner-actions"></span> | |
| 64 | + | <!-- Sandboxed: each kind of job it runs, Linux and Windows, on its own line. --> | |
| 65 | + | <ul class="capabilities" id="runner-capabilities" hidden></ul> | |
| 64 | 66 | </span> | |
| 65 | 67 | <button type="button" id="runner-toggle">Stop</button> | |
| 66 | 68 | </div> |
| 93 | 93 | return item; | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | + | // A fix's button. Choosing is done here, in this window; a copy says it was copied. | |
| 97 | + | function actionButton(action, primary) { | |
| 98 | + | var button = document.createElement("button"); | |
| 99 | + | button.type = "button"; | |
| 100 | + | button.textContent = action.label; | |
| 101 | + | if (primary) button.className = "primary"; | |
| 102 | + | button.addEventListener("click", function () { | |
| 103 | + | if (action.id === "choose_workspace") return byId("runnerWorkspace").focus(); | |
| 104 | + | if (action.id === "choose_backend") return byId("runnerBackend").focus(); | |
| 105 | + | invoke("runner_control", { action: action.id }).then(function () { | |
| 106 | + | if (action.id.indexOf("copy_") !== 0) return; | |
| 107 | + | button.textContent = "Copied"; | |
| 108 | + | setTimeout(function () { | |
| 109 | + | button.textContent = action.label; | |
| 110 | + | }, 2000); | |
| 111 | + | }, fail); | |
| 112 | + | }); | |
| 113 | + | return button; | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | function span(className, text) { | |
| 117 | + | var item = document.createElement("span"); | |
| 118 | + | if (className) item.className = className; | |
| 119 | + | item.textContent = text; | |
| 120 | + | return item; | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // Each kind of job the sandbox runs, when there is more than one: Linux | |
| 124 | + | // jobs can run while Windows jobs cannot. | |
| 125 | + | function renderCapabilities(capabilities) { | |
| 126 | + | var list = byId("runner-capabilities"); | |
| 127 | + | list.textContent = ""; | |
| 128 | + | list.hidden = !capabilities || capabilities.length < 2; | |
| 129 | + | if (list.hidden) return; | |
| 130 | + | capabilities.forEach(function (capability) { | |
| 131 | + | var item = document.createElement("li"); | |
| 132 | + | item.appendChild(span("dot " + capability.tone, "")); | |
| 133 | + | var text = span("diagnosis-text", ""); | |
| 134 | + | var title = document.createElement("strong"); | |
| 135 | + | title.textContent = capability.label + ": " + capability.title; | |
| 136 | + | text.appendChild(title); | |
| 137 | + | if (capability.cause) text.appendChild(span("muted", capability.cause)); | |
| 138 | + | if (capability.fix) text.appendChild(span("", capability.fix)); | |
| 139 | + | var buttons = span("buttons", ""); | |
| 140 | + | capability.actions.forEach(function (action) { | |
| 141 | + | buttons.appendChild(actionButton(action, false)); | |
| 142 | + | }); | |
| 143 | + | text.appendChild(buttons); | |
| 144 | + | item.appendChild(text); | |
| 145 | + | list.appendChild(item); | |
| 146 | + | }); | |
| 147 | + | } | |
| 148 | + | ||
| 96 | 149 | function renderRunner(runner) { | |
| 97 | 150 | if (!runner) return; | |
| 98 | 151 | var diagnosis = runner.diagnosis; | |
| ⋯ | |||
| 103 | 156 | var actions = byId("runner-actions"); | |
| 104 | 157 | actions.textContent = ""; | |
| 105 | 158 | diagnosis.actions.forEach(function (action, at) { | |
| 106 | − | var button = document.createElement("button"); | |
| 107 | − | button.type = "button"; | |
| 108 | − | button.textContent = action.label; | |
| 109 | − | if (at === 0) button.className = "primary"; | |
| 110 | − | button.addEventListener("click", function () { | |
| 111 | − | // Choosing is done here, in this window. | |
| 112 | − | if (action.id === "choose_workspace") return byId("runnerWorkspace").focus(); | |
| 113 | − | if (action.id === "choose_backend") return byId("runnerBackend").focus(); | |
| 114 | − | invoke("runner_control", { action: action.id }).catch(fail); | |
| 115 | − | }); | |
| 116 | − | actions.appendChild(button); | |
| 159 | + | actions.appendChild(actionButton(action, at === 0)); | |
| 117 | 160 | }); | |
| 161 | + | renderCapabilities(diagnosis.capabilities); | |
| 118 | 162 | byId("runner").checked = !!runner.enabled; | |
| 119 | 163 | var on = runner.enabled && !runner.paused; | |
| 120 | 164 | byId("runner-toggle").textContent = on ? "Stop" : "Start"; | |
| 3 | 3 | //! | |
| 4 | 4 | //! | Backend | How | What a job can reach | | |
| 5 | 5 | //! | --- | --- | --- | | |
| 6 | − | //! | Sandboxed (the default) | The runner in a sandbox of its own: its image under Sysbox on Linux today (container.rs), a VM that `g1t-runner sandbox` makes on Windows and macOS once it is built (launcher.rs). Jobs are containers inside it: `nested-container` isolation. | Its own container. Not this computer. | | |
| 6 | + | //! | Sandboxed (the default) | `g1t-runner sandbox` (launcher.rs): Linux jobs in a Linux VM of the runner's own (WSL 2 on Windows, vfkit on macOS, Sysbox or QEMU with KVM on Linux), each a container inside it, `nested-container`; on Windows 11 Pro and up, Windows jobs each in a Windows Sandbox, `vm`. What the computer lacks for it, the launcher says, and the app shows. | Its own container, or its own Windows Sandbox. Not this computer. | | |
| 7 | 7 | //! | Docker | `g1t-runner` on this computer, driving this computer's Docker (native.rs): `container` isolation. | Its own container; but the runner controls this computer's Docker, which on Docker Desktop is a VM with the person's files shared into it. Not a sandbox. | | |
| 8 | 8 | //! | On this computer | `g1t-runner --no-docker` (native.rs): `host` isolation. | Everything the person's account can. | | |
| 9 | 9 | //! | |
| ⋯ | |||
| 14 | 14 | use std::process::{Command, Stdio}; | |
| 15 | 15 | ||
| 16 | 16 | use serde::{Deserialize, Serialize}; | |
| 17 | − | ||
| 18 | − | use super::launcher; | |
| 19 | 17 | ||
| 20 | 18 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 21 | 19 | #[serde(rename_all = "snake_case")] | |
| ⋯ | |||
| 40 | 38 | /// What starts the runner for a backend, here. | |
| 41 | 39 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 42 | 40 | pub enum Engine { | |
| 43 | − | /// The runner's image under Sysbox, through this computer's Docker. | |
| 44 | − | Sysbox, | |
| 45 | − | /// `g1t-runner sandbox`, which makes and owns the sandbox. | |
| 41 | + | /// `g1t-runner sandbox`, which makes and owns the sandbox, and says | |
| 42 | + | /// itself what this computer lacks for one. | |
| 46 | 43 | Launcher, | |
| 47 | 44 | /// `g1t-runner` itself, as a child of the app; `docker` is whether its | |
| 48 | 45 | /// jobs run in this computer's Docker or directly on it. | |
| ⋯ | |||
| 54 | 51 | pub enum Missing { | |
| 55 | 52 | /// Docker is not installed, or not running. | |
| 56 | 53 | Docker, | |
| 57 | − | /// No sandbox can be made on this computer yet. | |
| 58 | − | Sandbox, | |
| 59 | − | /// Linux, whose sandbox needs Sysbox in its Docker, which has none. | |
| 60 | − | Sysbox, | |
| 61 | 54 | } | |
| 62 | 55 | ||
| 63 | 56 | /// A program run without a console window flashing up on Windows. | |
| ⋯ | |||
| 83 | 76 | .is_ok_and(|status| status.success()) | |
| 84 | 77 | } | |
| 85 | 78 | ||
| 86 | − | /// Whether this computer's Docker has Sysbox's runtime. | |
| 87 | − | fn sysbox_ready() -> bool { | |
| 88 | − | quiet("docker") | |
| 89 | − | .args(["info", "--format", "{{json .Runtimes}}"]) | |
| 90 | − | .stderr(Stdio::null()) | |
| 91 | − | .output() | |
| 92 | − | .is_ok_and(|out| out.status.success() && String::from_utf8_lossy(&out.stdout).contains("sysbox-runc")) | |
| 93 | − | } | |
| 94 | − | ||
| 95 | 79 | /// What a backend needs, checked now: how to start it, or why it cannot. | |
| 96 | 80 | pub fn engine(backend: Backend) -> Result<Engine, Missing> { | |
| 97 | 81 | match backend { | |
| 98 | − | Backend::Sandboxed => { | |
| 99 | − | if launcher::READY { | |
| 100 | − | return Ok(Engine::Launcher); | |
| 101 | − | } | |
| 102 | − | if !cfg!(target_os = "linux") { | |
| 103 | − | return Err(Missing::Sandbox); | |
| 104 | − | } | |
| 105 | − | if !docker_ready() { | |
| 106 | − | return Err(Missing::Docker); | |
| 107 | − | } | |
| 108 | − | if !sysbox_ready() { | |
| 109 | − | return Err(Missing::Sysbox); | |
| 110 | − | } | |
| 111 | − | Ok(Engine::Sysbox) | |
| 112 | − | } | |
| 82 | + | Backend::Sandboxed => Ok(Engine::Launcher), | |
| 113 | 83 | Backend::Docker => { | |
| 114 | 84 | if docker_ready() { | |
| 115 | 85 | Ok(Engine::Native { docker: true }) | |
| ⋯ | |||
| 156 | 126 | } | |
| 157 | 127 | ||
| 158 | 128 | #[test] | |
| 159 | − | fn on_this_computer_needs_nothing_more() { | |
| 129 | + | fn on_this_computer_needs_nothing_more_and_the_sandbox_is_the_launchers() { | |
| 160 | 130 | assert_eq!(engine(Backend::Host), Ok(Engine::Native { docker: false })); | |
| 131 | + | assert_eq!(engine(Backend::Sandboxed), Ok(Engine::Launcher)); | |
| 161 | 132 | } | |
| 162 | 133 | ||
| 163 | 134 | #[test] | |
| 1 | − | //! The Sandboxed backend on Linux today: the runner's image under Sysbox, | |
| 2 | − | //! through this computer's Docker, exactly as docs.g1t.sh/guides/ | |
| 3 | − | //! self-hosted-runners/#run-it-as-a-container runs it. Sysbox gives the | |
| 4 | − | //! runner's container a user namespace of its own and a Docker Engine | |
| 5 | − | //! inside it, so each job is a container inside the runner's container and | |
| 6 | − | //! neither reaches this computer (`nested-container` isolation). The | |
| 7 | − | //! machine's Docker socket is never given to it. | |
| 8 | − | //! | |
| 9 | − | //! The registration and credential are kept on a volume of their own, so | |
| 10 | − | //! the container can be replaced, when a newer image is out, without | |
| 11 | − | //! registering again. Keeping it up to date is pulling the image and, | |
| 12 | − | //! while the runner is idle, putting a new container in the old one's place. | |
| 13 | − | ||
| 14 | − | use std::process::{Child, Stdio}; | |
| 15 | − | ||
| 16 | − | use super::backend::quiet; | |
| 17 | − | ||
| 18 | − | /// The runner's image, published with each release. | |
| 19 | − | pub const IMAGE: &str = "g1t.sh/flagon-io/g1t-runner:latest"; | |
| 20 | − | /// The container the app runs, and the volume its registration is kept on. | |
| 21 | − | pub const NAME: &str = "g1t-desktop-runner"; | |
| 22 | − | pub const VOLUME: &str = "g1t-desktop-runner"; | |
| 23 | − | ||
| 24 | − | fn docker_ok(args: &[&str]) -> bool { | |
| 25 | − | quiet("docker").args(args).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|status| status.success()) | |
| 26 | − | } | |
| 27 | − | ||
| 28 | − | fn docker_text(args: &[&str]) -> Option<String> { | |
| 29 | − | let out = quiet("docker").args(args).stderr(Stdio::null()).output().ok()?; | |
| 30 | − | out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()).filter(|text| !text.is_empty()) | |
| 31 | − | } | |
| 32 | − | ||
| 33 | − | /// The arguments of the `docker run` that starts the runner. Each | |
| 34 | − | /// variable's value comes from the environment of the `docker` command, | |
| 35 | − | /// never its command line, so a registration token is not in the | |
| 36 | − | /// computer's list of processes. | |
| 37 | − | pub fn run_args(names: &[&str]) -> Vec<String> { | |
| 38 | − | let mut args: Vec<String> = ["run", "-d", "--name", NAME, "--runtime=sysbox-runc", "--label", "sh.g1t.desktop=runner", "-v"] | |
| 39 | − | .iter() | |
| 40 | − | .map(|s| s.to_string()) | |
| 41 | − | .collect(); | |
| 42 | − | args.push(format!("{VOLUME}:/data")); | |
| 43 | − | for name in names { | |
| 44 | − | args.push("-e".into()); | |
| 45 | − | args.push((*name).to_owned()); | |
| 46 | − | } | |
| 47 | − | args.push(IMAGE.into()); | |
| 48 | − | args.push("register-and-run".into()); | |
| 49 | − | args | |
| 50 | − | } | |
| 51 | − | ||
| 52 | − | /// Fetches the newest image; whether the one here changed. | |
| 53 | − | pub fn pull() -> bool { | |
| 54 | − | let before = docker_text(&["image", "inspect", "--format", "{{.Id}}", IMAGE]); | |
| 55 | − | let pulled = docker_ok(&["pull", "--quiet", IMAGE]); | |
| 56 | − | pulled && docker_text(&["image", "inspect", "--format", "{{.Id}}", IMAGE]) != before | |
| 57 | − | } | |
| 58 | − | ||
| 59 | − | /// Whether the image is here at all. | |
| 60 | − | pub fn have_image() -> bool { | |
| 61 | − | docker_ok(&["image", "inspect", IMAGE]) | |
| 62 | − | } | |
| 63 | − | ||
| 64 | − | /// Whether the running container is from an older image than the one here. | |
| 65 | − | pub fn outdated() -> bool { | |
| 66 | − | let image = docker_text(&["image", "inspect", "--format", "{{.Id}}", IMAGE]); | |
| 67 | − | let running = docker_text(&["inspect", "--format", "{{.Image}}", NAME]); | |
| 68 | − | matches!((image, running), (Some(image), Some(running)) if image != running) | |
| 69 | − | } | |
| 70 | − | ||
| 71 | − | /// Whether a registration is kept here. | |
| 72 | − | pub fn registered() -> bool { | |
| 73 | − | docker_ok(&["volume", "inspect", VOLUME]) | |
| 74 | − | } | |
| 75 | − | ||
| 76 | − | /// Starts the runner's container in place of any earlier one, then | |
| 77 | − | /// follows its log: the child's output is the runner's. | |
| 78 | − | pub fn start(envs: &[(&str, String)]) -> Result<Child, String> { | |
| 79 | − | let _ = docker_ok(&["rm", "-f", NAME]); | |
| 80 | − | let names: Vec<&str> = envs.iter().map(|(name, _)| *name).collect(); | |
| 81 | − | let out = quiet("docker") | |
| 82 | − | .args(run_args(&names)) | |
| 83 | − | .envs(envs.iter().map(|(name, value)| (*name, value.as_str()))) | |
| 84 | − | .stdout(Stdio::null()) | |
| 85 | − | .output() | |
| 86 | − | .map_err(|e| format!("could not run docker: {e}"))?; | |
| 87 | − | if !out.status.success() { | |
| 88 | − | return Err(String::from_utf8_lossy(&out.stderr).trim().to_owned()); | |
| 89 | − | } | |
| 90 | − | quiet("docker") | |
| 91 | − | .args(["logs", "--follow", NAME]) | |
| 92 | − | .stdout(Stdio::piped()) | |
| 93 | − | .stderr(Stdio::piped()) | |
| 94 | − | .spawn() | |
| 95 | − | .map_err(|e| format!("could not follow the runner's log: {e}")) | |
| 96 | − | } | |
| 97 | − | ||
| 98 | − | /// How the container's runner ended, while the container is still there. | |
| 99 | − | pub fn exit_code() -> Option<i32> { | |
| 100 | − | docker_text(&["inspect", "--format", "{{.State.ExitCode}}", NAME]).and_then(|code| code.parse().ok()) | |
| 101 | − | } | |
| 102 | − | ||
| 103 | − | /// Stops the container and removes it; its volume stays. | |
| 104 | − | pub fn stop(logs: Option<&mut Child>) { | |
| 105 | − | let _ = docker_ok(&["stop", "--time", "20", NAME]); | |
| 106 | − | let _ = docker_ok(&["rm", "-f", NAME]); | |
| 107 | − | if let Some(logs) = logs { | |
| 108 | − | let _ = logs.kill(); | |
| 109 | − | let _ = logs.wait(); | |
| 110 | − | } | |
| 111 | − | } | |
| 112 | − | ||
| 113 | − | /// Unregisters the runner kept on the volume, then removes the volume. | |
| 114 | − | pub fn forget() { | |
| 115 | − | if registered() { | |
| 116 | − | let mut args = vec!["run", "--rm", "--runtime=sysbox-runc", "-v"]; | |
| 117 | − | let mount = format!("{VOLUME}:/data"); | |
| 118 | − | args.push(&mount); | |
| 119 | − | args.extend([IMAGE, "remove"]); | |
| 120 | − | let _ = docker_ok(&args); | |
| 121 | − | let _ = docker_ok(&["volume", "rm", "-f", VOLUME]); | |
| 122 | − | } | |
| 123 | − | } | |
| 124 | − | ||
| 125 | − | #[cfg(test)] | |
| 126 | − | mod tests { | |
| 127 | − | use super::*; | |
| 128 | − | ||
| 129 | − | #[test] | |
| 130 | − | fn the_runner_runs_under_sysbox_without_the_machines_docker() { | |
| 131 | − | let args = run_args(&["G1T_RUNNER_URL", "G1T_RUNNER_TOKEN"]); | |
| 132 | − | assert!(args.contains(&"--runtime=sysbox-runc".to_owned())); | |
| 133 | − | assert!(args.contains(&"g1t-desktop-runner:/data".to_owned())); | |
| 134 | − | assert!(!args.iter().any(|a| a.contains("docker.sock") || a == "--privileged"), "{args:?}"); | |
| 135 | − | // Values never on the command line: only the names. | |
| 136 | − | assert!(args.windows(2).any(|w| w[0] == "-e" && w[1] == "G1T_RUNNER_TOKEN")); | |
| 137 | − | assert_eq!(&args[args.len() - 2..], [IMAGE, "register-and-run"]); | |
| 138 | − | } | |
| 139 | − | } |
| 8 | 8 | //! | |
| 9 | 9 | //! Each action is an id the settings window and the site turn into a | |
| 10 | 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, …) are said in its words, with a fix and its | |
| 15 | + | //! buttons; any other is shown as the launcher wrote it. With the sandbox, | |
| 16 | + | //! each kind of job it runs (Linux jobs, Windows jobs) has a line of its | |
| 17 | + | //! own as well (`capabilities`), since one can run while the other cannot. | |
| 11 | 18 | ||
| 12 | 19 | use serde::Serialize; | |
| 13 | 20 | ||
| 21 | + | use super::launcher::{self, State, Status, Why}; | |
| 22 | + | ||
| 14 | 23 | /// Every state the runner can be in. | |
| 15 | 24 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] | |
| 16 | 25 | #[serde(rename_all = "snake_case")] | |
| ⋯ | |||
| 33 | 42 | Idle, | |
| 34 | 43 | Busy, | |
| 35 | 44 | NoDocker, | |
| 36 | − | NoSandbox, | |
| 45 | + | /// The `g1t-runner` here is from before its sandbox launcher. | |
| 46 | + | NoLauncher, | |
| 47 | + | /// Windows, without WSL, which holds the sandbox's Linux VM. | |
| 48 | + | NoWsl, | |
| 49 | + | /// WSL set to mirrored networking, which reaches this computer's localhost. | |
| 50 | + | WslMirrored, | |
| 51 | + | /// The computer's virtualization is turned off. | |
| 52 | + | NoVirtualization, | |
| 53 | + | /// macOS, without vfkit, which runs the sandbox's VM. | |
| 54 | + | NoVfkit, | |
| 55 | + | /// Linux, with neither Sysbox in Docker nor KVM. | |
| 37 | 56 | NoSysbox, | |
| 57 | + | /// The sandbox's image couldn't be downloaded, or isn't the release's. | |
| 58 | + | ImageFailed, | |
| 59 | + | /// The sandbox can't be made here, for a reason in the launcher's words. | |
| 60 | + | SandboxUnavailable, | |
| 61 | + | /// The sandbox failed, in the launcher's words. | |
| 62 | + | SandboxFailed, | |
| 63 | + | /// The computer has a sandbox the app didn't make, and has room for one. | |
| 64 | + | SandboxTaken, | |
| 38 | 65 | Updating, | |
| 39 | 66 | UpdateFailed, | |
| 40 | 67 | Offline, | |
| ⋯ | |||
| 70 | 97 | /// What to do about it. | |
| 71 | 98 | pub fix: Option<String>, | |
| 72 | 99 | pub actions: Vec<Action>, | |
| 100 | + | /// With the sandbox: each kind of job it runs, on its own line. | |
| 101 | + | #[serde(skip_serializing_if = "Vec::is_empty")] | |
| 102 | + | pub capabilities: Vec<CapabilityLine>, | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// One kind of job the sandbox runs, `linux` or `windows` (or `macos`), | |
| 106 | + | /// and how it is: Linux jobs can run while Windows jobs cannot. | |
| 107 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize)] | |
| 108 | + | pub struct CapabilityLine { | |
| 109 | + | pub id: String, | |
| 110 | + | /// `Linux jobs`. | |
| 111 | + | pub label: String, | |
| 112 | + | pub tone: Tone, | |
| 113 | + | /// `Waiting for jobs`, `Not available`. | |
| 114 | + | pub title: String, | |
| 115 | + | pub cause: Option<String>, | |
| 116 | + | pub fix: Option<String>, | |
| 117 | + | pub actions: Vec<Action>, | |
| 73 | 118 | } | |
| 74 | 119 | ||
| 75 | 120 | /// What the words are filled in with. | |
| ⋯ | |||
| 86 | 131 | /// The server's host: `g1t.sh`. | |
| 87 | 132 | pub server: String, | |
| 88 | 133 | pub platform: String, | |
| 134 | + | /// What the sandbox says to do, for a cause in its own words. | |
| 135 | + | pub fix: Option<String>, | |
| 136 | + | /// How the sandbox is, when jobs run sandboxed. | |
| 137 | + | pub sandbox: Option<Status>, | |
| 89 | 138 | } | |
| 90 | 139 | ||
| 91 | 140 | const START: Action = Action { id: "start", label: "Start" }; | |
| ⋯ | |||
| 99 | 148 | const OPEN_DOCKER: Action = Action { id: "open_docker", label: "Open Docker" }; | |
| 100 | 149 | const GET_DOCKER: Action = Action { id: "get_docker", label: "Get Docker" }; | |
| 101 | 150 | const INSTALL_SYSBOX: Action = Action { id: "install_sysbox", label: "Install Sysbox" }; | |
| 151 | + | const COPY_WSL_INSTALL: Action = Action { id: "copy_wsl_install", label: "Copy command" }; | |
| 152 | + | const COPY_VFKIT_INSTALL: Action = Action { id: "copy_vfkit_install", label: "Copy command" }; | |
| 153 | + | const WINDOWS_FEATURES: Action = Action { id: "windows_features", label: "Open Windows features" }; | |
| 102 | 154 | const LEARN_MORE: Action = Action { id: "learn_more", label: "Learn more" }; | |
| 103 | 155 | const VIEW_LOG: Action = Action { id: "view_log", label: "View log" }; | |
| 104 | 156 | const REPORT: Action = Action { id: "report", label: "Report" }; | |
| ⋯ | |||
| 190 | 242 | if app == "Docker Desktop" { vec![OPEN_DOCKER, GET_DOCKER] } else { vec![GET_DOCKER] }, | |
| 191 | 243 | ) | |
| 192 | 244 | } | |
| 193 | − | Code::NoSandbox => ( | |
| 245 | + | Code::NoLauncher => ( | |
| 194 | 246 | Tone::Warning, | |
| 195 | − | "Not running: a sandbox isn't available on this computer yet".into(), | |
| 196 | − | some("The sandbox keeps everything a job does away from this computer. It isn't built for this system yet."), | |
| 197 | − | some("Wait for it, or choose to run jobs in Docker or directly on this computer. Neither is a sandbox: settings say what each can reach."), | |
| 198 | − | vec![LEARN_MORE, CHOOSE_BACKEND], | |
| 247 | + | "Not running: this g1t-runner has no sandbox".into(), | |
| 248 | + | some("The g1t-runner g1t downloaded is older than its sandbox launcher, so it can't make a sandbox."), | |
| 249 | + | some("g1t looks for a newer one by itself; 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."), | |
| 250 | + | vec![RETRY, CHOOSE_BACKEND], | |
| 251 | + | ), | |
| 252 | + | Code::NoWsl => ( | |
| 253 | + | Tone::Problem, | |
| 254 | + | "Not running: WSL isn't installed".into(), | |
| 255 | + | some("Linux jobs run in a WSL distribution of the runner's own, and WSL isn't installed on this computer."), | |
| 256 | + | some(format!("Run `{}` in an admin terminal, then restart Windows. It works on every edition of Windows 11, Home included.", launcher::WSL_INSTALL)), | |
| 257 | + | vec![COPY_WSL_INSTALL, LEARN_MORE], | |
| 258 | + | ), | |
| 259 | + | Code::WslMirrored => ( | |
| 260 | + | Tone::Problem, | |
| 261 | + | "Not running: WSL uses mirrored networking".into(), | |
| 262 | + | some("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost, so the sandbox won't start."), | |
| 263 | + | some("Remove networkingMode=mirrored from %USERPROFILE%\\.wslconfig, run `wsl --shutdown`, then Retry."), | |
| 264 | + | vec![RETRY, LEARN_MORE], | |
| 199 | 265 | ), | |
| 266 | + | Code::NoVirtualization => ( | |
| 267 | + | Tone::Problem, | |
| 268 | + | "Not running: virtualization is off".into(), | |
| 269 | + | some("The sandbox is a small virtual machine, and this computer has virtualization turned off."), | |
| 270 | + | some(if facts.platform == "windows" { | |
| 271 | + | "Turn on virtualization (Intel VT-x or AMD-V) in this computer's firmware settings, and Virtual Machine Platform in Windows features; then restart." | |
| 272 | + | } else { | |
| 273 | + | "Turn on virtualization (Intel VT-x or AMD-V) in this computer's firmware settings, then restart." | |
| 274 | + | }), | |
| 275 | + | if facts.platform == "windows" { vec![WINDOWS_FEATURES, LEARN_MORE] } else { vec![RETRY, LEARN_MORE] }, | |
| 276 | + | ), | |
| 277 | + | Code::NoVfkit => ( | |
| 278 | + | Tone::Problem, | |
| 279 | + | "Not running: vfkit isn't installed".into(), | |
| 280 | + | some("Linux jobs run in a small VM that vfkit runs with Apple's Virtualization framework, and vfkit isn't installed."), | |
| 281 | + | some(format!("Run `{}` in Terminal (macOS 13 or later). The runner starts by itself once it's there.", launcher::VFKIT_INSTALL)), | |
| 282 | + | vec![COPY_VFKIT_INSTALL, LEARN_MORE], | |
| 283 | + | ), | |
| 200 | 284 | Code::NoSysbox => ( | |
| 201 | 285 | Tone::Problem, | |
| 202 | − | "Not running: Sysbox isn't installed".into(), | |
| 203 | − | some("The sandbox on Linux is the runner's image under Sysbox, and this computer's Docker doesn't have Sysbox."), | |
| 204 | − | some("Install Sysbox; the runner starts by itself once Docker has it."), | |
| 286 | + | "Not running: no Sysbox or KVM".into(), | |
| 287 | + | some("The sandbox on Linux is the runner's image under Sysbox, or a VM under QEMU with KVM, and this computer has neither."), | |
| 288 | + | 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."), | |
| 205 | 289 | vec![INSTALL_SYSBOX, CHOOSE_BACKEND], | |
| 206 | 290 | ), | |
| 291 | + | Code::ImageFailed => ( | |
| 292 | + | Tone::Problem, | |
| 293 | + | "Not running: the sandbox's image didn't download".into(), | |
| 294 | + | detail.or_else(|| some("The sandbox's image couldn't be downloaded, or wasn't the release's, so it wasn't used.")), | |
| 295 | + | some("It's only ever used once its signature and SHA-256 are the release's. g1t tries again by itself; check the connection, or report it if it keeps happening."), | |
| 296 | + | vec![RETRY, REPORT], | |
| 297 | + | ), | |
| 298 | + | Code::SandboxUnavailable => ( | |
| 299 | + | Tone::Problem, | |
| 300 | + | "Not running: the sandbox can't run here".into(), | |
| 301 | + | detail.or_else(|| some("The sandbox can't be made on this computer.")), | |
| 302 | + | 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.")), | |
| 303 | + | vec![RETRY, LEARN_MORE, CHOOSE_BACKEND], | |
| 304 | + | ), | |
| 305 | + | Code::SandboxFailed => ( | |
| 306 | + | Tone::Problem, | |
| 307 | + | "Not running: the sandbox failed".into(), | |
| 308 | + | detail.or_else(|| some("The sandbox stopped, and didn't say why.")), | |
| 309 | + | Some(match facts.wait_seconds { | |
| 310 | + | Some(wait) => format!("It starts again by itself in {wait} seconds. Retry now, or look at its log."), | |
| 311 | + | None => "Retry, or look at its log.".into(), | |
| 312 | + | }), | |
| 313 | + | vec![RETRY, VIEW_LOG], | |
| 314 | + | ), | |
| 315 | + | Code::SandboxTaken => ( | |
| 316 | + | Tone::Warning, | |
| 317 | + | "Not running: this computer has another sandbox".into(), | |
| 318 | + | some("This computer already has a g1t-runner sandbox that g1t didn't make, such as one started with `g1t-runner sandbox up`, and it has room for one."), | |
| 319 | + | some("Remove it with `g1t-runner sandbox down --delete`, then Retry; its runners stay listed on g1t until you remove them there. Or keep it, and turn off running jobs in g1t."), | |
| 320 | + | vec![RETRY, LEARN_MORE], | |
| 321 | + | ), | |
| 207 | 322 | Code::Updating => (Tone::Working, "Updating".into(), detail, some("The runner starts again as soon as it's done."), vec![]), | |
| 208 | 323 | Code::UpdateFailed => ( | |
| 209 | 324 | Tone::Warning, | |
| ⋯ | |||
| 233 | 348 | vec![RESTART, VIEW_LOG], | |
| 234 | 349 | ), | |
| 235 | 350 | }; | |
| 236 | − | Diagnosis { code, tone, title, cause, fix, actions } | |
| 351 | + | let capabilities = facts.sandbox.as_ref().map(|status| capabilities(status, facts)).unwrap_or_default(); | |
| 352 | + | Diagnosis { code, tone, title, cause, fix, actions, capabilities } | |
| 237 | 353 | } | |
| 238 | 354 | ||
| 355 | + | /// What the app calls a sandbox's problem with Linux jobs, from the | |
| 356 | + | /// launcher's sentence about it, which goes with it as its detail. | |
| 357 | + | pub fn sandbox_code(why: Why) -> Code { | |
| 358 | + | match why { | |
| 359 | + | Why::NoWsl => Code::NoWsl, | |
| 360 | + | Why::WslMirrored => Code::WslMirrored, | |
| 361 | + | Why::NoVirtualization => Code::NoVirtualization, | |
| 362 | + | Why::NoVfkit => Code::NoVfkit, | |
| 363 | + | Why::NoSysboxOrKvm => Code::NoSysbox, | |
| 364 | + | Why::Image => Code::ImageFailed, | |
| 365 | + | _ => Code::SandboxFailed, | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | /// The code for what is wrong with Linux jobs, as a status says it, and | |
| 370 | + | /// the launcher's own words for it. | |
| 371 | + | pub fn sandbox_trouble(status: &Status) -> Option<(Code, String, Option<String>)> { | |
| 372 | + | let (cause, fix) = status.trouble()?; | |
| 373 | + | let code = match launcher::why(&cause) { | |
| 374 | + | Why::Other if !status.linux().available => Code::SandboxUnavailable, | |
| 375 | + | why => sandbox_code(why), | |
| 376 | + | }; | |
| 377 | + | Some((code, cause, fix)) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | /// Each kind of job the sandbox runs, on a line of its own. | |
| 381 | + | fn capabilities(status: &Status, facts: &Facts) -> Vec<CapabilityLine> { | |
| 382 | + | let listed = if status.capabilities.is_empty() { vec![status.linux()] } else { status.capabilities.clone() }; | |
| 383 | + | listed.iter().map(|capability| capability_line(status, capability, facts)).collect() | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | fn capability_line(status: &Status, capability: &launcher::Capability, facts: &Facts) -> CapabilityLine { | |
| 387 | + | let label = match capability.id.as_str() { | |
| 388 | + | "linux" => "Linux jobs".to_owned(), | |
| 389 | + | "windows" => "Windows jobs".to_owned(), | |
| 390 | + | "macos" => "macOS jobs".to_owned(), | |
| 391 | + | other => format!("{other} jobs"), | |
| 392 | + | }; | |
| 393 | + | let line = |tone: Tone, title: String, cause: Option<String>, fix: Option<String>, actions: Vec<Action>| CapabilityLine { | |
| 394 | + | id: capability.id.clone(), | |
| 395 | + | label: label.clone(), | |
| 396 | + | tone, | |
| 397 | + | title, | |
| 398 | + | cause, | |
| 399 | + | fix, | |
| 400 | + | actions, | |
| 401 | + | }; | |
| 402 | + | let title = if capability.available { "Not running" } else { "Not available" }; | |
| 403 | + | if capability.id == "linux" { | |
| 404 | + | if let Some((code, cause, fix)) = sandbox_trouble(status) { | |
| 405 | + | // The same words as the whole runner's when Linux jobs are what is wrong. | |
| 406 | + | let said = diagnose(code, &Facts { detail: Some(cause), fix, wait_seconds: None, sandbox: None, ..facts.clone() }); | |
| 407 | + | return line(Tone::Problem, title.into(), said.cause, said.fix, said.actions); | |
| 408 | + | } | |
| 409 | + | } else if !capability.available || capability.state == State::Error { | |
| 410 | + | let cause = capability.cause.clone(); | |
| 411 | + | return match cause.as_deref().map(launcher::why) { | |
| 412 | + | Some(Why::WindowsSandboxOff) => line( | |
| 413 | + | Tone::Off, | |
| 414 | + | title.into(), | |
| 415 | + | some("Windows Sandbox is off, or this edition of Windows has none: Home doesn't."), | |
| 416 | + | some("Linux jobs still run. For Windows jobs, on Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox in Windows features, then restart."), | |
| 417 | + | vec![WINDOWS_FEATURES], | |
| 418 | + | ), | |
| 419 | + | Some(Why::NoWsb) => line( | |
| 420 | + | Tone::Off, | |
| 421 | + | title.into(), | |
| 422 | + | some("This Windows has Windows Sandbox, but not its wsb command, which came with Windows 11 24H2."), | |
| 423 | + | some("Linux jobs still run. For Windows jobs, update Windows to 24H2 or later."), | |
| 424 | + | vec![], | |
| 425 | + | ), | |
| 426 | + | Some(Why::MacNotReleased) => line(Tone::Off, title.into(), some("Sandboxed macOS jobs aren't released yet."), some("Linux jobs still run."), vec![]), | |
| 427 | + | _ => line(if capability.available { Tone::Problem } else { Tone::Off }, title.into(), cause, capability.fix.clone(), vec![]), | |
| 428 | + | }; | |
| 429 | + | } | |
| 430 | + | match capability.state { | |
| 431 | + | State::Running => match &capability.job { | |
| 432 | + | Some(job) => line(Tone::Working, format!("Running {}", job.said()), None, None, vec![]), | |
| 433 | + | None => line(Tone::Ok, "Waiting for jobs".into(), None, None, vec![]), | |
| 434 | + | }, | |
| 435 | + | State::Starting => line(Tone::Working, "Starting".into(), None, None, vec![]), | |
| 436 | + | State::Updating => line(Tone::Working, "Updating".into(), None, None, vec![]), | |
| 437 | + | State::Stopped => line(Tone::Off, "Stopped".into(), None, None, vec![]), | |
| 438 | + | State::Absent => line(Tone::Off, "Not set up yet".into(), None, None, vec![]), | |
| 439 | + | State::Error | State::Unknown => line(Tone::Off, "Not running".into(), capability.cause.clone(), capability.fix.clone(), vec![]), | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | ||
| 239 | 443 | /// What the site says of this computer while g1t is closed on it. | |
| 240 | 444 | pub fn closed(facts: &Facts) -> Diagnosis { | |
| 241 | 445 | Diagnosis { | |
| ⋯ | |||
| 245 | 449 | cause: some(format!("This runner runs while g1t is open on {}.", facts.machine)), | |
| 246 | 450 | fix: some(format!("Open g1t on {} to run jobs there again.", facts.machine)), | |
| 247 | 451 | actions: vec![], | |
| 452 | + | capabilities: vec![], | |
| 248 | 453 | } | |
| 249 | 454 | } | |
| 250 | 455 | ||
| ⋯ | |||
| 258 | 463 | ||
| 259 | 464 | #[test] | |
| 260 | 465 | fn every_failure_says_why_and_how_to_fix_it() { | |
| 261 | − | for code in [Code::SignedOut, Code::NoWorkspace, Code::NotAllowed, Code::RegisterFailed, Code::NoDocker, Code::NoSandbox, Code::NoSysbox, Code::UpdateFailed, Code::Offline, Code::Crashed] { | |
| 466 | + | for code in [Code::SignedOut, Code::NoWorkspace, Code::NotAllowed, Code::RegisterFailed, Code::NoDocker, | |
| 467 | + | Code::NoLauncher, Code::NoWsl, Code::WslMirrored, Code::NoVirtualization, Code::NoVfkit, Code::NoSysbox, Code::ImageFailed, Code::SandboxUnavailable, Code::SandboxFailed, | |
| 468 | + | Code::SandboxTaken, Code::UpdateFailed, Code::Offline, Code::Crashed, | |
| 469 | + | ] { | |
| 262 | 470 | let diagnosis = diagnose(code, &Facts { detail: Some("why".into()), ..facts() }); | |
| 263 | 471 | assert!(diagnosis.cause.is_some(), "{code:?} has no cause"); | |
| 264 | 472 | assert!(diagnosis.fix.is_some(), "{code:?} has no fix"); | |
| ⋯ | |||
| 281 | 489 | assert_eq!(diagnose(Code::NoDocker, &facts()).actions[0].id, "open_docker"); | |
| 282 | 490 | assert_eq!(closed(&facts()).title, "g1t is closed on build-box"); | |
| 283 | 491 | } | |
| 492 | + | ||
| 493 | + | fn status(json: &str) -> Status { | |
| 494 | + | launcher::parse(json).unwrap() | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | const NO_WSL_PRO: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1", | |
| 498 | + | "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.", | |
| 499 | + | "capabilities":[ | |
| 500 | + | {"id":"linux","available":false,"state":"error","isolation":"nested-container", | |
| 501 | + | "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.", | |
| 502 | + | "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."}, | |
| 503 | + | {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.2.1","job":null}]}"#; | |
| 504 | + | ||
| 505 | + | #[test] | |
| 506 | + | fn without_wsl_the_fix_is_the_command_to_copy_and_windows_jobs_still_show() { | |
| 507 | + | let status = status(NO_WSL_PRO); | |
| 508 | + | let (code, cause, _) = sandbox_trouble(&status).unwrap(); | |
| 509 | + | assert_eq!(code, Code::NoWsl); | |
| 510 | + | let diagnosis = diagnose(code, &Facts { detail: Some(cause), sandbox: Some(status), ..facts() }); | |
| 511 | + | assert_eq!(diagnosis.title, "Not running: WSL isn't installed"); | |
| 512 | + | 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.")); | |
| 513 | + | assert_eq!(diagnosis.actions[0], COPY_WSL_INSTALL); | |
| 514 | + | let [linux, windows] = &diagnosis.capabilities[..] else { panic!("{:?}", diagnosis.capabilities) }; | |
| 515 | + | assert_eq!((linux.label.as_str(), linux.title.as_str(), linux.tone), ("Linux jobs", "Not available", Tone::Problem)); | |
| 516 | + | assert_eq!(linux.fix, diagnosis.fix); | |
| 517 | + | assert_eq!(linux.actions[0].id, "copy_wsl_install"); | |
| 518 | + | assert_eq!((windows.label.as_str(), windows.title.as_str(), windows.tone), ("Windows jobs", "Waiting for jobs", Tone::Ok)); | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | #[test] | |
| 522 | + | fn on_home_windows_jobs_are_unavailable_and_linux_jobs_still_run() { | |
| 523 | + | let status = status( | |
| 524 | + | r#"{"schema":1,"state":"running","backend":"wsl2","runner_version":"0.3.0","launcher_version":"0.3.0", | |
| 525 | + | "job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"}, | |
| 526 | + | "capabilities":[ | |
| 527 | + | {"id":"linux","available":true,"state":"running","runner_version":"0.3.0","job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"}}, | |
| 528 | + | {"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"On Windows 11 Pro…"}]}"#, | |
| 529 | + | ); | |
| 530 | + | assert_eq!(sandbox_trouble(&status), None); | |
| 531 | + | let busy = diagnose(Code::Busy, &Facts { job: status.job().map(launcher::Job::said), sandbox: Some(status), ..facts() }); | |
| 532 | + | assert_eq!(busy.title, "Running build (acme/web)"); | |
| 533 | + | let [linux, windows] = &busy.capabilities[..] else { panic!() }; | |
| 534 | + | assert_eq!((linux.title.as_str(), linux.tone), ("Running build (acme/web)", Tone::Working)); | |
| 535 | + | assert_eq!((windows.title.as_str(), windows.tone), ("Not available", Tone::Off)); | |
| 536 | + | assert_eq!(windows.cause.as_deref(), Some("Windows Sandbox is off, or this edition of Windows has none: Home doesn't.")); | |
| 537 | + | assert!(windows.fix.as_deref().unwrap().starts_with("Linux jobs still run.")); | |
| 538 | + | assert_eq!(windows.actions[0].id, "windows_features"); | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | #[test] | |
| 542 | + | fn each_launcher_trouble_has_its_words() { | |
| 543 | + | let trouble = |linux_available: bool, text: &str| { | |
| 544 | + | let json = serde_json::json!({ | |
| 545 | + | "schema": 1, "state": "error", "error": text, | |
| 546 | + | "capabilities": [{"id": "linux", "available": linux_available, "state": "error", "cause": if linux_available { None } else { Some(text) }, "fix": "the launcher's fix"}] | |
| 547 | + | }); | |
| 548 | + | sandbox_trouble(&status(&json.to_string())).unwrap() | |
| 549 | + | }; | |
| 550 | + | 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); | |
| 551 | + | assert_eq!(trouble(false, "vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.").0, Code::NoVfkit); | |
| 552 | + | assert_eq!(trouble(false, "This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.").0, Code::NoSysbox); | |
| 553 | + | assert_eq!(trouble(false, "WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.").0, Code::WslMirrored); | |
| 554 | + | 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); | |
| 555 | + | // Unknown words: shown as the launcher wrote them, its fix with them. | |
| 556 | + | let (code, cause, fix) = trouble(false, "This user may not use /dev/kvm."); | |
| 557 | + | assert_eq!(code, Code::SandboxUnavailable); | |
| 558 | + | let unavailable = diagnose(code, &Facts { detail: Some(cause), fix, ..facts() }); | |
| 559 | + | assert_eq!(unavailable.cause.as_deref(), Some("This user may not use /dev/kvm.")); | |
| 560 | + | assert_eq!(unavailable.fix.as_deref(), Some("the launcher's fix")); | |
| 561 | + | let (code, cause, _) = trouble(true, "the sandbox did not start within 600 seconds"); | |
| 562 | + | assert_eq!(code, Code::SandboxFailed); | |
| 563 | + | let failed = diagnose(code, &Facts { detail: Some(cause), wait_seconds: Some(60), ..facts() }); | |
| 564 | + | assert_eq!(failed.cause.as_deref(), Some("the sandbox did not start within 600 seconds")); | |
| 565 | + | assert_eq!(failed.fix.as_deref(), Some("It starts again by itself in 60 seconds. Retry now, or look at its log.")); | |
| 566 | + | let image = diagnose(Code::ImageFailed, &Facts { detail: Some("could not fetch https://g1t.sh/x: dns error".into()), ..facts() }); | |
| 567 | + | assert_eq!(image.cause.as_deref(), Some("could not fetch https://g1t.sh/x: dns error")); | |
| 568 | + | assert_eq!(diagnose(Code::NoVirtualization, &Facts { platform: "macos".into(), ..facts() }).actions[0].id, "retry"); | |
| 569 | + | assert_eq!(diagnose(Code::NoVfkit, &facts()).actions[0].id, "copy_vfkit_install"); | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | #[test] | |
| 573 | + | fn a_diagnosis_without_the_sandbox_lists_no_capabilities() { | |
| 574 | + | let json = serde_json::to_value(diagnose(Code::Idle, &Facts { backend: "in Docker", ..facts() })).unwrap(); | |
| 575 | + | assert!(json.get("capabilities").is_none()); | |
| 576 | + | let sandboxed = serde_json::to_value(diagnose(Code::Idle, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() })).unwrap(); | |
| 577 | + | assert_eq!(sandboxed["capabilities"][1]["id"], "windows"); | |
| 578 | + | } | |
| 284 | 579 | } | |
| 1 | − | //! The one place the app meets `g1t-runner sandbox`: the launcher that | |
| 2 | − | //! makes a Linux VM of its own on this computer (Hyper-V or WSL on | |
| 3 | − | //! Windows, Apple's Virtualization framework on macOS, Sysbox or KVM on | |
| 4 | − | //! Linux) and runs the runner inside it, so jobs are containers inside the | |
| 5 | − | //! runner's sandbox and none of it reaches this computer | |
| 6 | − | //! (`nested-container` isolation). | |
| 1 | + | //! The one place the app meets `g1t-runner sandbox`, the launcher that | |
| 2 | + | //! makes a sandbox of the runner's own on this computer and runs the | |
| 3 | + | //! runner inside it (crates/runner/src/selfhosted/launcher): Linux jobs in | |
| 4 | + | //! a Linux VM (a WSL 2 distribution on Windows, vfkit on macOS, Sysbox or | |
| 5 | + | //! QEMU with KVM on Linux), each a fresh container inside it | |
| 6 | + | //! (`nested-container`), and, on Windows 11 Pro and up, Windows jobs each | |
| 7 | + | //! in a Windows Sandbox of its own (`vm`). | |
| 7 | 8 | //! | |
| 8 | − | //! It is being built in crates/runner. Until it is released, `READY` is | |
| 9 | − | //! false: the Sandboxed backend uses the runner's image under Sysbox where | |
| 10 | − | //! this computer's Docker has it (container.rs), and says a sandbox isn't | |
| 11 | − | //! available everywhere else. The commands below are the expected shape | |
| 12 | − | //! (`sandbox up|down|update`, configured like `register-and-run`, from | |
| 13 | − | //! `G1T_RUNNER_*` in the environment); when the launcher lands, this file | |
| 14 | − | //! is the only one that changes. | |
| 9 | + | //! It is the `g1t-runner` the app already keeps up to date (native.rs), | |
| 10 | + | //! with its state in the app's runner folder (`--state-dir`). The app runs | |
| 11 | + | //! four commands, each with `--json`, and reads the one object each prints | |
| 12 | + | //! last (the launcher's documented status, schema 1): | |
| 13 | + | //! | |
| 14 | + | //! - `up --url … --name … --labels desktop [--token …]`, after the window | |
| 15 | + | //! opens: makes the sandbox if there is none and starts it, waiting until | |
| 16 | + | //! the runner asks for work. The token goes only to the first `up`; the | |
| 17 | + | //! launcher keeps the registration from then on, across updates too. It | |
| 18 | + | //! adds `-linux` and `-windows` to the name, and the OS labels, itself. | |
| 19 | + | //! - `status`, every few seconds, for the tray, settings and the site. | |
| 20 | + | //! - `down` as g1t quits or the person stops it; `down --drain` when they | |
| 21 | + | //! choose another way of running jobs; `down --delete` when the | |
| 22 | + | //! registration is no longer the one wanted. | |
| 23 | + | //! - `update`, while nothing runs: the newest signed image, the same | |
| 24 | + | //! registration. | |
| 25 | + | //! | |
| 26 | + | //! What a status's causes and errors mean is read here (`why`); what the | |
| 27 | + | //! app says about them is diagnosis.rs's. | |
| 15 | 28 | ||
| 29 | + | use std::ffi::OsString; | |
| 16 | 30 | use std::path::Path; | |
| 17 | 31 | use std::process::{Command, Stdio}; | |
| 32 | + | use std::time::Duration; | |
| 33 | + | ||
| 34 | + | use serde::Deserialize; | |
| 18 | 35 | ||
| 19 | 36 | use super::backend::quiet; | |
| 20 | 37 | ||
| 21 | − | /// Whether the released `g1t-runner` has its sandbox launcher. | |
| 22 | − | pub const READY: bool = false; | |
| 38 | + | /// The launcher's state folder, under the app's runner folder. | |
| 39 | + | pub const FOLDER: &str = "sandbox"; | |
| 40 | + | /// The status shape the app reads (`schema`). | |
| 41 | + | pub const SCHEMA: u32 = 1; | |
| 42 | + | /// How often the app asks the sandbox how it is: a call takes about a second. | |
| 43 | + | pub const STATUS_EVERY: Duration = Duration::from_secs(8); | |
| 44 | + | /// What installs WSL on Windows, for the person to run as administrator. | |
| 45 | + | pub const WSL_INSTALL: &str = "wsl --install --no-distribution"; | |
| 46 | + | /// What installs vfkit on a Mac. | |
| 47 | + | pub const VFKIT_INSTALL: &str = "brew install vfkit"; | |
| 48 | + | ||
| 49 | + | /// A sandbox's, or one capability's, state. | |
| 50 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] | |
| 51 | + | #[serde(rename_all = "snake_case")] | |
| 52 | + | pub enum State { | |
| 53 | + | /// Never made, or deleted. | |
| 54 | + | #[default] | |
| 55 | + | Absent, | |
| 56 | + | Stopped, | |
| 57 | + | Starting, | |
| 58 | + | Running, | |
| 59 | + | Updating, | |
| 60 | + | Error, | |
| 61 | + | /// A state a newer launcher has that this app does not know. | |
| 62 | + | #[serde(other)] | |
| 63 | + | Unknown, | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// What a runner is running now. | |
| 67 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] | |
| 68 | + | #[serde(default)] | |
| 69 | + | pub struct Job { | |
| 70 | + | /// `workflow` or `agent`. | |
| 71 | + | pub kind: String, | |
| 72 | + | pub id: String, | |
| 73 | + | pub name: String, | |
| 74 | + | pub repo: String, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | impl Job { | |
| 78 | + | /// How the tray says it: `build (acme/web)`. | |
| 79 | + | pub fn said(&self) -> String { | |
| 80 | + | if self.repo.is_empty() { self.name.clone() } else { format!("{} ({})", self.name, self.repo) } | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// One kind of job the computer can run sandboxed: `linux`, `windows` or `macos`. | |
| 85 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] | |
| 86 | + | #[serde(default)] | |
| 87 | + | pub struct Capability { | |
| 88 | + | pub id: String, | |
| 89 | + | pub available: bool, | |
| 90 | + | pub state: State, | |
| 91 | + | pub isolation: Option<String>, | |
| 92 | + | pub runner_version: Option<String>, | |
| 93 | + | pub job: Option<Job>, | |
| 94 | + | /// Why it is not available, in a sentence. | |
| 95 | + | pub cause: Option<String>, | |
| 96 | + | /// What to do about it, in a sentence. | |
| 97 | + | pub fix: Option<String>, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | /// `g1t-runner sandbox status --json`. The top-level fields are the Linux | |
| 101 | + | /// VM's. Fields the app does not use (`resources`, `state_dir`) are left | |
| 102 | + | /// out, and any a newer launcher adds are ignored. | |
| 103 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] | |
| 104 | + | #[serde(default)] | |
| 105 | + | pub struct Status { | |
| 106 | + | pub schema: u32, | |
| 107 | + | pub state: State, | |
| 108 | + | /// `wsl2`, `vfkit`, `qemu` or `sysbox`. | |
| 109 | + | pub backend: Option<String>, | |
| 110 | + | pub runner_version: Option<String>, | |
| 111 | + | pub launcher_version: Option<String>, | |
| 112 | + | pub image_version: Option<String>, | |
| 113 | + | pub isolation: Option<String>, | |
| 114 | + | pub job: Option<Job>, | |
| 115 | + | /// A sentence for a person, whenever something is wrong. | |
| 116 | + | pub error: Option<String>, | |
| 117 | + | pub capabilities: Vec<Capability>, | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | impl Status { | |
| 121 | + | pub fn capability(&self, id: &str) -> Option<&Capability> { | |
| 122 | + | self.capabilities.iter().find(|c| c.id == id) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// The Linux capability, as the top-level fields say it when a launcher lists none. | |
| 126 | + | pub fn linux(&self) -> Capability { | |
| 127 | + | self.capability("linux").cloned().unwrap_or_else(|| Capability { | |
| 128 | + | id: "linux".into(), | |
| 129 | + | available: self.state != State::Error || self.error.is_none(), | |
| 130 | + | state: self.state, | |
| 131 | + | runner_version: self.runner_version.clone(), | |
| 132 | + | job: self.job.clone(), | |
| 133 | + | ..Capability::default() | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// Whether any of its runners is running. | |
| 138 | + | pub fn running(&self) -> bool { | |
| 139 | + | self.state == State::Running || self.capabilities.iter().any(|c| c.state == State::Running) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /// Whether anything here can be sandboxed at all. | |
| 143 | + | pub fn anything_available(&self) -> bool { | |
| 144 | + | if self.capabilities.is_empty() { self.linux().available } else { self.capabilities.iter().any(|c| c.available) } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// A capability that can be had here but was never set up: registering | |
| 148 | + | /// it needs a token. | |
| 149 | + | pub fn unregistered(&self) -> bool { | |
| 150 | + | let wanting = |c: &Capability| c.available && c.state == State::Absent; | |
| 151 | + | if self.capabilities.is_empty() { wanting(&self.linux()) } else { self.capabilities.iter().any(wanting) } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// What a runner is running, the Linux one's first. | |
| 155 | + | pub fn job(&self) -> Option<&Job> { | |
| 156 | + | self.job.as_ref().or_else(|| self.capabilities.iter().find_map(|c| c.job.as_ref())) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// The runner's version, as the tray and the site say it. | |
| 160 | + | pub fn version(&self) -> Option<String> { | |
| 161 | + | self.runner_version | |
| 162 | + | .clone() | |
| 163 | + | .or_else(|| self.capabilities.iter().find_map(|c| c.runner_version.clone())) | |
| 164 | + | .or_else(|| self.launcher_version.clone()) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// What is wrong with Linux jobs, in the launcher's words: its error, | |
| 168 | + | /// else why the capability is unavailable. | |
| 169 | + | pub fn trouble(&self) -> Option<(String, Option<String>)> { | |
| 170 | + | let linux = self.linux(); | |
| 171 | + | if !linux.available { | |
| 172 | + | let cause = linux.cause.or_else(|| self.error.clone())?; | |
| 173 | + | return Some((cause, linux.fix)); | |
| 174 | + | } | |
| 175 | + | if linux.state == State::Error || self.state == State::Error { | |
| 176 | + | return Some((self.error.clone().unwrap_or_else(|| "The sandbox stopped, and didn't say why.".into()), None)); | |
| 177 | + | } | |
| 178 | + | None | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /// Reads what a launcher command printed: log lines, then the status as | |
| 183 | + | /// one JSON object, which starts on a line of its own. A launcher whose | |
| 184 | + | /// schema is not this app's is not read, nor is a `g1t-runner` from before | |
| 185 | + | /// the launcher, which prints no status at all. | |
| 186 | + | pub fn parse(output: &str) -> Result<Status, String> { | |
| 187 | + | let start = output | |
| 188 | + | .match_indices('\n') | |
| 189 | + | .map(|(at, _)| at + 1) | |
| 190 | + | .chain(std::iter::once(0)) | |
| 191 | + | .filter(|at| output[*at..].starts_with('{')) | |
| 192 | + | .max() | |
| 193 | + | .ok_or_else(|| "this g1t-runner printed no sandbox status: it is older than its sandbox launcher".to_owned())?; | |
| 194 | + | let status: Status = serde_json::from_str(output[start..].trim()).map_err(|e| format!("the sandbox's status doesn't read: {e}"))?; | |
| 195 | + | if status.schema != SCHEMA { | |
| 196 | + | return Err(format!("the sandbox's status is in schema {}, and this g1t reads schema {SCHEMA}: update g1t", status.schema)); | |
| 197 | + | } | |
| 198 | + | Ok(status) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /// Whether a line of a launcher's output starts its status, rather than being a log line. | |
| 202 | + | pub fn starts_status(line: &str) -> bool { | |
| 203 | + | line.starts_with('{') | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// What `up` is told the first time, and again on every start (the | |
| 207 | + | /// launcher keeps what it was told, and `--token` only matters once). | |
| 208 | + | #[derive(Clone, PartialEq, Eq)] | |
| 209 | + | pub struct Up<'a> { | |
| 210 | + | pub url: &'a str, | |
| 211 | + | pub name: &'a str, | |
| 212 | + | pub labels: &'a str, | |
| 213 | + | /// The registration token: only for a sandbox that has no registration yet. | |
| 214 | + | pub token: Option<&'a str>, | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // Never print a token, in a log or a panic. | |
| 218 | + | impl std::fmt::Debug for Up<'_> { | |
| 219 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 220 | + | f.debug_struct("Up").field("url", &self.url).field("name", &self.name).field("labels", &self.labels).field("token", &self.token.map(|_| "…")).finish() | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | fn args(action: &str, dir: &Path) -> Vec<OsString> { | |
| 225 | + | let mut args: Vec<OsString> = vec!["sandbox".into(), action.into(), "--json".into(), "--state-dir".into()]; | |
| 226 | + | args.push(dir.as_os_str().to_owned()); | |
| 227 | + | args | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | /// `sandbox up`'s arguments. | |
| 231 | + | pub fn up_args(dir: &Path, up: &Up) -> Vec<OsString> { | |
| 232 | + | let mut args = args("up", dir); | |
| 233 | + | for (name, value) in [("--url", up.url), ("--name", up.name), ("--labels", up.labels)] { | |
| 234 | + | args.push(name.into()); | |
| 235 | + | args.push(value.into()); | |
| 236 | + | } | |
| 237 | + | if let Some(token) = up.token { | |
| 238 | + | args.push("--token".into()); | |
| 239 | + | args.push(token.into()); | |
| 240 | + | } | |
| 241 | + | args | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | /// How `down` stops the sandbox. | |
| 245 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 246 | + | pub enum Down { | |
| 247 | + | /// Now: a running job is cut short (as g1t quits, or Stop). | |
| 248 | + | Now, | |
| 249 | + | /// After the running job (another way of running jobs was chosen). | |
| 250 | + | Drain, | |
| 251 | + | /// Now, and the VM and the registration with it (another workspace or server). | |
| 252 | + | Delete, | |
| 253 | + | } | |
| 23 | 254 | ||
| 24 | − | /// The launcher's own folder under the app's runner folder: its VM and the | |
| 25 | − | /// runner's registration inside it are kept there. | |
| 26 | − | pub const FOLDER: &str = "sandbox"; | |
| 255 | + | pub fn down_args(dir: &Path, how: Down) -> Vec<OsString> { | |
| 256 | + | let mut args = args("down", dir); | |
| 257 | + | match how { | |
| 258 | + | Down::Now => {} | |
| 259 | + | Down::Drain => args.push("--drain".into()), | |
| 260 | + | Down::Delete => args.push("--delete".into()), | |
| 261 | + | } | |
| 262 | + | args | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | pub fn status_args(dir: &Path) -> Vec<OsString> { | |
| 266 | + | args("status", dir) | |
| 267 | + | } | |
| 27 | 268 | ||
| 28 | − | fn sandbox(binary: &Path, action: &str, dir: &Path) -> Command { | |
| 269 | + | pub fn update_args(dir: &Path) -> Vec<OsString> { | |
| 270 | + | args("update", dir) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | /// A launcher command whose output the app reads a line at a time | |
| 274 | + | /// (`up` and `update`, which take minutes the first time). | |
| 275 | + | pub fn command(binary: &Path, args: Vec<OsString>) -> Command { | |
| 29 | 276 | let mut command = quiet(binary); | |
| 30 | − | command.args(["sandbox", action, "--dir"]).arg(dir); | |
| 277 | + | // What the app's own environment may have set for another runner is not this one's. | |
| 278 | + | for (name, _) in std::env::vars_os() { | |
| 279 | + | if name.to_string_lossy().starts_with("G1T_RUNNER_") { | |
| 280 | + | command.env_remove(&name); | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | command.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); | |
| 31 | 284 | command | |
| 32 | 285 | } | |
| 33 | 286 | ||
| 34 | − | /// Makes the sandbox if it is not there, starts it, and runs the runner | |
| 35 | − | /// inside it until `down`: a child whose output is the runner's log. | |
| 36 | − | pub fn up(binary: &Path, dir: &Path) -> Command { | |
| 37 | − | let mut command = sandbox(binary, "up", dir); | |
| 38 | − | command.stdout(Stdio::piped()).stderr(Stdio::piped()); | |
| 39 | − | command | |
| 287 | + | /// Runs a short launcher command to its end and reads its status, which | |
| 288 | + | /// it prints whether it worked or not. | |
| 289 | + | fn run(binary: &Path, args: Vec<OsString>) -> Result<Status, String> { | |
| 290 | + | let out = command(binary, args).output().map_err(|e| format!("g1t-runner could not start: {e}"))?; | |
| 291 | + | let printed = String::from_utf8_lossy(&out.stdout); | |
| 292 | + | match parse(&printed) { | |
| 293 | + | Ok(status) => Ok(status), | |
| 294 | + | Err(_) if !out.status.success() && !out.stderr.is_empty() => Err(String::from_utf8_lossy(&out.stderr).trim().trim_start_matches("g1t-runner: ").to_owned()), | |
| 295 | + | Err(why) => Err(why), | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /// How the sandbox is now. | |
| 300 | + | pub fn status(binary: &Path, dir: &Path) -> Result<Status, String> { | |
| 301 | + | run(binary, status_args(dir)) | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | /// Stops the sandbox, waiting for it. | |
| 305 | + | pub fn down(binary: &Path, dir: &Path, how: Down) -> Result<(), String> { | |
| 306 | + | if !binary.exists() { | |
| 307 | + | return Err("g1t-runner isn't installed".into()); | |
| 308 | + | } | |
| 309 | + | let status = run(binary, down_args(dir, how))?; | |
| 310 | + | match status.error { | |
| 311 | + | Some(error) => Err(error), | |
| 312 | + | None => Ok(()), | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | /// What a launcher's sentence is about: the words the app shows for it | |
| 317 | + | /// are diagnosis.rs's. Read from the causes and errors the launcher | |
| 318 | + | /// writes (crates/runner/src/selfhosted/launcher and native.rs). | |
| 319 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 320 | + | pub enum Why { | |
| 321 | + | NoWsl, | |
| 322 | + | WslMirrored, | |
| 323 | + | NoVirtualization, | |
| 324 | + | NoVfkit, | |
| 325 | + | /// Linux, with neither Sysbox in Docker nor KVM. | |
| 326 | + | NoSysboxOrKvm, | |
| 327 | + | /// The sandbox's image couldn't be downloaded, or isn't the release's. | |
| 328 | + | Image, | |
| 329 | + | /// g1t refused the registration token. | |
| 330 | + | TokenRefused, | |
| 331 | + | /// g1t no longer knows the runner. | |
| 332 | + | Forgotten, | |
| 333 | + | /// The sandbox has no registration, and no token was given. | |
| 334 | + | Unregistered, | |
| 335 | + | /// Windows Sandbox is off, or this edition of Windows has none. | |
| 336 | + | WindowsSandboxOff, | |
| 337 | + | /// Windows Sandbox without `wsb`, from before Windows 11 24H2. | |
| 338 | + | NoWsb, | |
| 339 | + | /// Sandboxed macOS jobs are not released. | |
| 340 | + | MacNotReleased, | |
| 341 | + | /// Anything else: shown in the launcher's own words. | |
| 342 | + | Other, | |
| 40 | 343 | } | |
| 41 | 344 | ||
| 42 | − | /// Stops the runner and the sandbox around it, waiting for neither job nor VM to linger. | |
| 43 | − | pub fn down(binary: &Path, dir: &Path) { | |
| 44 | − | let _ = sandbox(binary, "down", dir).stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 345 | + | pub fn why(text: &str) -> Why { | |
| 346 | + | let lower = text.to_ascii_lowercase(); | |
| 347 | + | let has = |words: &str| lower.contains(words); | |
| 348 | + | if has("wsl is not installed") { | |
| 349 | + | Why::NoWsl | |
| 350 | + | } else if has("mirrored") { | |
| 351 | + | Why::WslMirrored | |
| 352 | + | } else if has("virtualization is not enabled") | |
| 353 | + | || has("enable virtualization") | |
| 354 | + | || has("virtual machine platform") | |
| 355 | + | || has("hcs_e_hyperv_not_installed") | |
| 356 | + | || has("vt-x") | |
| 357 | + | || has("hypervisor is not present") | |
| 358 | + | { | |
| 359 | + | Why::NoVirtualization | |
| 360 | + | } else if has("vfkit") && has("not installed") { | |
| 361 | + | Why::NoVfkit | |
| 362 | + | } else if has("neither sysbox nor kvm") || has("docker has no sysbox") { | |
| 363 | + | Why::NoSysboxOrKvm | |
| 364 | + | } else if has("token") && (has("not valid") || has("expired") || has("invalid")) { | |
| 365 | + | Why::TokenRefused | |
| 366 | + | } else if has("no longer knows this runner") || has("was removed from g1t") { | |
| 367 | + | Why::Forgotten | |
| 368 | + | } else if has("is not registered") { | |
| 369 | + | Why::Unregistered | |
| 370 | + | } else if has("sandbox image") || has("sandbox's images") || has("no sandbox images") || has("sha-256") || has("signature") || has("release key") || has("could not fetch") || has("broke off") { | |
| 371 | + | Why::Image | |
| 372 | + | } else if has("windows sandbox is not turned on") { | |
| 373 | + | Why::WindowsSandboxOff | |
| 374 | + | } else if has("24h2") { | |
| 375 | + | Why::NoWsb | |
| 376 | + | } else if has("macos jobs are not released") { | |
| 377 | + | Why::MacNotReleased | |
| 378 | + | } else { | |
| 379 | + | Why::Other | |
| 380 | + | } | |
| 45 | 381 | } | |
| 46 | 382 | ||
| 47 | − | /// Brings the sandbox's image to the release of `binary`; true when it changed. | |
| 48 | − | pub fn update(binary: &Path, dir: &Path) -> bool { | |
| 49 | − | sandbox(binary, "update", dir).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|status| status.success()) | |
| 383 | + | #[cfg(test)] | |
| 384 | + | mod tests { | |
| 385 | + | use super::*; | |
| 386 | + | use std::path::PathBuf; | |
| 387 | + | ||
| 388 | + | /// What `status --json` printed on a Windows 11 Pro machine with WSL | |
| 389 | + | /// and Windows Sandbox, before `up` (g1t-runner 0.2.1). | |
| 390 | + | const ABSENT: &str = r#"{ | |
| 391 | + | "schema": 1, | |
| 392 | + | "state": "absent", | |
| 393 | + | "backend": "wsl2", | |
| 394 | + | "runner_version": null, | |
| 395 | + | "launcher_version": "0.2.1", | |
| 396 | + | "image_version": null, | |
| 397 | + | "isolation": "nested-container", | |
| 398 | + | "job": null, | |
| 399 | + | "error": null, | |
| 400 | + | "resources": { | |
| 401 | + | "cpus": 0, | |
| 402 | + | "memory_mib": 0, | |
| 403 | + | "disk_gib": 0 | |
| 404 | + | }, | |
| 405 | + | "capabilities": [ | |
| 406 | + | { | |
| 407 | + | "id": "linux", | |
| 408 | + | "available": true, | |
| 409 | + | "state": "absent", | |
| 410 | + | "isolation": "nested-container", | |
| 411 | + | "runner_version": null, | |
| 412 | + | "job": null, | |
| 413 | + | "cause": null, | |
| 414 | + | "fix": null | |
| 415 | + | }, | |
| 416 | + | { | |
| 417 | + | "id": "windows", | |
| 418 | + | "available": true, | |
| 419 | + | "state": "absent", | |
| 420 | + | "isolation": "vm", | |
| 421 | + | "runner_version": null, | |
| 422 | + | "job": null, | |
| 423 | + | "cause": null, | |
| 424 | + | "fix": null | |
| 425 | + | } | |
| 426 | + | ], | |
| 427 | + | "state_dir": "C:\\Users\\syntaqx\\.g1t-runner\\sandbox" | |
| 428 | + | }"#; | |
| 429 | + | ||
| 430 | + | /// The launcher's documented example: running a job, Windows Sandbox off. | |
| 431 | + | const RUNNING: &str = r#"{ | |
| 432 | + | "schema": 1, "state": "running", "backend": "wsl2", | |
| 433 | + | "runner_version": "0.3.0", "launcher_version": "0.3.0", "image_version": "0.3.0", | |
| 434 | + | "isolation": "nested-container", | |
| 435 | + | "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" }, | |
| 436 | + | "error": null, | |
| 437 | + | "resources": { "cpus": 4, "memory_mib": 8192, "disk_gib": 64 }, | |
| 438 | + | "capabilities": [ | |
| 439 | + | { "id": "linux", "available": true, "state": "running", "isolation": "nested-container", | |
| 440 | + | "runner_version": "0.3.0", "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" }, "cause": null, "fix": null }, | |
| 441 | + | { "id": "windows", "available": false, "state": "absent", "isolation": "vm", | |
| 442 | + | "runner_version": null, "job": null, | |
| 443 | + | "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).", | |
| 444 | + | "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." } | |
| 445 | + | ], | |
| 446 | + | "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox", | |
| 447 | + | "something_new": { "a": 1 } | |
| 448 | + | }"#; | |
| 449 | + | ||
| 450 | + | #[test] | |
| 451 | + | fn a_status_is_read_from_after_the_log() { | |
| 452 | + | let printed = format!("2026-10-12T08:00:00Z Downloading the sandbox image wsl-x64.\n2026-10-12T08:03:00Z {{not json}}\n{RUNNING}\n"); | |
| 453 | + | let status = parse(&printed).unwrap(); | |
| 454 | + | assert_eq!(status.state, State::Running); | |
| 455 | + | assert_eq!(status.backend.as_deref(), Some("wsl2")); | |
| 456 | + | assert_eq!(status.job().map(Job::said).as_deref(), Some("build (acme/web)")); | |
| 457 | + | assert_eq!(status.version().as_deref(), Some("0.3.0")); | |
| 458 | + | assert!(status.running()); | |
| 459 | + | assert!(!status.unregistered()); | |
| 460 | + | let windows = status.capability("windows").unwrap(); | |
| 461 | + | assert!(!windows.available); | |
| 462 | + | assert_eq!(why(windows.cause.as_deref().unwrap()), Why::WindowsSandboxOff); | |
| 463 | + | assert_eq!(status.trouble(), None); | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | #[test] | |
| 467 | + | fn a_sandbox_never_made_needs_registering() { | |
| 468 | + | let status = parse(ABSENT).unwrap(); | |
| 469 | + | assert_eq!(status.state, State::Absent); | |
| 470 | + | assert!(status.anything_available()); | |
| 471 | + | assert!(status.unregistered()); | |
| 472 | + | assert!(!status.running()); | |
| 473 | + | assert_eq!(status.version().as_deref(), Some("0.2.1")); | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | #[test] | |
| 477 | + | fn what_is_not_a_status_is_said_so() { | |
| 478 | + | assert!(parse("g1t-runner: unknown command sandbox\n").unwrap_err().contains("older than its sandbox launcher")); | |
| 479 | + | assert!(parse(&ABSENT.replace("\"schema\": 1", "\"schema\": 2")).unwrap_err().contains("schema 2")); | |
| 480 | + | let unknown = parse(&ABSENT.replace("\"state\": \"absent\",\n \"backend\"", "\"state\": \"hibernating\",\n \"backend\"")).unwrap(); | |
| 481 | + | assert_eq!(unknown.state, State::Unknown); | |
| 482 | + | assert!(starts_status("{") && !starts_status(" \"schema\": 1,")); | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | #[test] | |
| 486 | + | fn without_wsl_linux_says_why_and_nothing_else_can_run_on_home() { | |
| 487 | + | let status = parse( | |
| 488 | + | r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1", | |
| 489 | + | "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.", | |
| 490 | + | "capabilities":[ | |
| 491 | + | {"id":"linux","available":false,"state":"error", | |
| 492 | + | "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.", | |
| 493 | + | "fix":"Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows."}, | |
| 494 | + | {"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"…"}]}"#, | |
| 495 | + | ) | |
| 496 | + | .unwrap(); | |
| 497 | + | assert!(!status.anything_available()); | |
| 498 | + | let (cause, fix) = status.trouble().unwrap(); | |
| 499 | + | assert_eq!(why(&cause), Why::NoWsl); | |
| 500 | + | assert!(fix.unwrap().contains(WSL_INSTALL)); | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | #[test] | |
| 504 | + | fn the_launchers_sentences_are_told_apart() { | |
| 505 | + | for (text, expected) in [ | |
| 506 | + | ("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.", Why::WslMirrored), | |
| 507 | + | ("WSL could not import the sandbox: Please enable the Virtual Machine Platform Windows feature and ensure virtualization is enabled in the BIOS.", Why::NoVirtualization), | |
| 508 | + | ("vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.", Why::NoVfkit), | |
| 509 | + | ("This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.", Why::NoSysboxOrKvm), | |
| 510 | + | ("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it", Why::Image), | |
| 511 | + | ("could not fetch https://g1t.sh/downloads/runner/0.3.0/wsl-x64.tar.gz: dns error", Why::Image), | |
| 512 | + | ("release 0.3.0 has no sandbox images", Why::Image), | |
| 513 | + | ("The registration token is not valid, or has expired.", Why::TokenRefused), | |
| 514 | + | ("g1t no longer knows this runner (401 unauthorized). Register it again.", Why::Forgotten), | |
| 515 | + | ("the sandbox's runner is not registered, and no --url and --token were given", Why::Unregistered), | |
| 516 | + | ("This Windows has Windows Sandbox but not its command line, wsb, which came in Windows 11 24H2.", Why::NoWsb), | |
| 517 | + | ("Sandboxed macOS jobs are not released yet.", Why::MacNotReleased), | |
| 518 | + | ("This user may not use /dev/kvm.", Why::Other), | |
| 519 | + | ] { | |
| 520 | + | assert_eq!(why(text), expected, "{text}"); | |
| 521 | + | } | |
| 522 | + | } | |
| 523 | + | ||
| 524 | + | #[test] | |
| 525 | + | fn the_commands_are_the_launchers() { | |
| 526 | + | let dir = PathBuf::from("data").join("runner").join(FOLDER); | |
| 527 | + | let text = |args: Vec<OsString>| args.iter().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>(); | |
| 528 | + | let first = Up { url: "https://g1t.sh", name: "build-box", labels: "desktop", token: Some("g1trt_secret") }; | |
| 529 | + | let up = text(up_args(&dir, &first)); | |
| 530 | + | assert_eq!(&up[..4], ["sandbox", "up", "--json", "--state-dir"]); | |
| 531 | + | assert_eq!(up[4], dir.to_string_lossy()); | |
| 532 | + | assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop", "--token", "g1trt_secret"]); | |
| 533 | + | // Its name and labels as the app has them: the launcher adds `-linux` and the OS labels. | |
| 534 | + | assert!(!up.iter().any(|a| a.ends_with("-linux") || a.contains("windows"))); | |
| 535 | + | let again = text(up_args(&dir, &Up { token: None, ..first.clone() })); | |
| 536 | + | assert!(!again.iter().any(|a| a == "--token" || a.starts_with("g1trt_"))); | |
| 537 | + | assert!(!format!("{first:?}").contains("g1trt_secret")); | |
| 538 | + | assert_eq!(text(down_args(&dir, Down::Now))[..3], ["sandbox", "down", "--json"]); | |
| 539 | + | assert_eq!(text(down_args(&dir, Down::Now)).len(), 5); | |
| 540 | + | assert_eq!(text(down_args(&dir, Down::Drain)).last().unwrap(), "--drain"); | |
| 541 | + | assert_eq!(text(down_args(&dir, Down::Delete)).last().unwrap(), "--delete"); | |
| 542 | + | assert_eq!(text(status_args(&dir))[..3], ["sandbox", "status", "--json"]); | |
| 543 | + | assert_eq!(text(update_args(&dir))[..3], ["sandbox", "update", "--json"]); | |
| 544 | + | } | |
| 50 | 545 | } |
| 11 | 11 | //! - **How.** The backend settings choose (backend.rs): Sandboxed, the | |
| 12 | 12 | //! default, where nothing a job does reaches this computer; Docker; or | |
| 13 | 13 | //! directly on this computer. The two that are not sandboxes are only | |
| 14 | − | //! ever chosen by the person, and nothing falls back to them. | |
| 14 | + | //! ever chosen by the person, and nothing falls back to them. Sandboxed | |
| 15 | + | //! is `g1t-runner sandbox` (launcher.rs) on every OS: `up` a few seconds | |
| 16 | + | //! after the window, `status` every few seconds while it runs, `down` | |
| 17 | + | //! as g1t quits, and `down --drain` when another backend is chosen. | |
| 15 | 18 | //! - **Registering.** The first time, and when the workspace or backend | |
| 16 | 19 | //! changes, the app needs a registration token for the workspace. Only | |
| 17 | 20 | //! the site can make one, for an owner who is signed in: the app asks | |
| 18 | 21 | //! the site's page (`g1t-runner-wanted`), the page asks the server | |
| 19 | 22 | //! (`POST /-/desktop/runner-token`) and hands the token to the app | |
| 20 | − | //! (`runner_register` in bridge.rs), which gives it to `g1t-runner` in | |
| 21 | − | //! its environment, once. It is never shown or written to the log. The | |
| 22 | − | //! registration is kept (registration.json, and the runner's own folder | |
| 23 | − | //! or volume) and used on every later start. | |
| 23 | + | //! (`runner_register` in bridge.rs), which gives it to `g1t-runner` | |
| 24 | + | //! once: in its environment, or to the sandbox's first `up`. It is never | |
| 25 | + | //! shown or written to the log. The registration is kept | |
| 26 | + | //! (registration.json and the runner's own folder; the sandbox keeps its | |
| 27 | + | //! own, and sandbox-registration.json says whose it is) and used on | |
| 28 | + | //! every later start. | |
| 24 | 29 | //! - **Up to date.** Soon after it starts and every six hours, while the | |
| 25 | − | //! runner is idle, the app looks for a newer release, or a newer image, | |
| 26 | − | //! and puts it in place: never in the middle of a job. | |
| 30 | + | //! runner is idle, the app looks for a newer release and puts it in | |
| 31 | + | //! place, and has the sandbox take the newest image (`sandbox update`): | |
| 32 | + | //! never in the middle of a job. | |
| 27 | 33 | //! - **Seen.** What it is doing, and when it is not running why and how to | |
| 28 | 34 | //! fix it (diagnosis.rs), is in the tray, in settings, and on the site's | |
| 29 | 35 | //! Runners page (`g1t-runner-status`), read from what its log says | |
| 30 | − | //! (log.rs). The whole log is kept in `runner/runner.log`. | |
| 36 | + | //! (log.rs), or for the sandbox from its status. The whole log is kept | |
| 37 | + | //! in `runner/runner.log`. | |
| 31 | 38 | //! | |
| 32 | 39 | //! All of it runs on a thread of its own, which owns the runner's process | |
| 33 | 40 | //! and is told what happened through one channel. | |
| 34 | 41 | ||
| 35 | 42 | mod backend; | |
| 36 | − | mod container; | |
| 37 | 43 | pub mod diagnosis; | |
| 38 | 44 | mod launcher; | |
| 39 | 45 | mod log; | |
| ⋯ | |||
| 43 | 49 | use std::fs::{File, OpenOptions}; | |
| 44 | 50 | use std::io::{BufRead, BufReader, Read, Write}; | |
| 45 | 51 | use std::path::PathBuf; | |
| 46 | − | use std::process::Child; | |
| 52 | + | use std::process::{Child, Stdio}; | |
| 47 | 53 | use std::sync::Mutex; | |
| 48 | 54 | use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; | |
| 49 | 55 | use std::time::{Duration, Instant}; | |
| ⋯ | |||
| 55 | 61 | pub use backend::Backend; | |
| 56 | 62 | use backend::{Engine, Missing}; | |
| 57 | 63 | use diagnosis::{Code, Diagnosis, Facts}; | |
| 64 | + | use launcher::{Down, State, Status}; | |
| 58 | 65 | ||
| 59 | 66 | use crate::{config, frame, settings, tray}; | |
| 60 | 67 | ||
| ⋯ | |||
| 101 | 108 | wait_seconds: Option<u64>, | |
| 102 | 109 | job: Option<String>, | |
| 103 | 110 | version: Option<String>, | |
| 111 | + | /// What the sandbox says to do, for a cause in its own words. | |
| 112 | + | fix: Option<String>, | |
| 113 | + | /// How the sandbox is, while jobs run sandboxed. | |
| 114 | + | sandbox: Option<Status>, | |
| 104 | 115 | } | |
| 105 | 116 | ||
| 106 | 117 | /// What the site last said about the person. | |
| ⋯ | |||
| 157 | 168 | Retry, | |
| 158 | 169 | Token { workspace: String, token: String }, | |
| 159 | 170 | Refused { workspace: String, allowed: bool, why: String }, | |
| 160 | − | Line(u64, String), | |
| 171 | + | /// A line of the runner's output, or the launcher's; true from its standard output. | |
| 172 | + | Line(u64, String, bool), | |
| 161 | 173 | Exited(u64), | |
| 174 | + | /// What `sandbox status` said. | |
| 175 | + | Sandbox(u64, Result<Box<Status>, String>), | |
| 162 | 176 | Quit(Sender<()>), | |
| 163 | 177 | } | |
| 164 | 178 | ||
| ⋯ | |||
| 170 | 184 | backend: Backend, | |
| 171 | 185 | } | |
| 172 | 186 | ||
| 187 | + | /// Whose the sandbox's registration is. The sandbox keeps its own, and | |
| 188 | + | /// keeps it while another backend runs, so choosing Sandboxed again needs | |
| 189 | + | /// no new one; this says which workspace it is for. | |
| 190 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 191 | + | struct SandboxRegistration { | |
| 192 | + | server: String, | |
| 193 | + | workspace: String, | |
| 194 | + | /// False from the `up` that was given a token until it registered. | |
| 195 | + | registered: bool, | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | impl SandboxRegistration { | |
| 199 | + | fn path(app: &AppHandle) -> PathBuf { | |
| 200 | + | folder(app).join("sandbox-registration.json") | |
| 201 | + | } | |
| 202 | + | fn load(app: &AppHandle) -> Option<SandboxRegistration> { | |
| 203 | + | std::fs::read(Self::path(app)).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok()) | |
| 204 | + | } | |
| 205 | + | fn save(&self, app: &AppHandle) { | |
| 206 | + | let _ = std::fs::create_dir_all(folder(app)); | |
| 207 | + | let _ = std::fs::write(Self::path(app), serde_json::to_vec_pretty(self).unwrap_or_default()); | |
| 208 | + | } | |
| 209 | + | fn clear(app: &AppHandle) { | |
| 210 | + | let _ = std::fs::remove_file(Self::path(app)); | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 173 | 214 | fn folder(app: &AppHandle) -> PathBuf { | |
| 174 | 215 | app.path().app_data_dir().unwrap_or_else(|_| std::env::temp_dir().join("sh.g1t.desktop")).join("runner") | |
| 175 | 216 | } | |
| ⋯ | |||
| 217 | 258 | ||
| 218 | 259 | fn facts(app: &AppHandle, now: &Now) -> Facts { | |
| 219 | 260 | let server = config::server(app); | |
| 261 | + | let backend = config::settings(app).runner_backend; | |
| 220 | 262 | Facts { | |
| 221 | 263 | workspace: workspace(app), | |
| 222 | − | backend: config::settings(app).runner_backend.label(), | |
| 264 | + | backend: backend.label(), | |
| 223 | 265 | machine: backend::machine_name(), | |
| 224 | 266 | job: now.job.clone(), | |
| 225 | 267 | detail: now.detail.clone(), | |
| ⋯ | |||
| 227 | 269 | wait_seconds: now.wait_seconds, | |
| 228 | 270 | server: server.host_str().unwrap_or("g1t").to_owned(), | |
| 229 | 271 | platform: std::env::consts::OS.into(), | |
| 272 | + | fix: now.fix.clone(), | |
| 273 | + | sandbox: now.sandbox.clone().filter(|_| backend == Backend::Sandboxed), | |
| 230 | 274 | } | |
| 231 | 275 | } | |
| 232 | 276 | ||
| ⋯ | |||
| 239 | 283 | View { | |
| 240 | 284 | diagnosis: diagnosis::diagnose(now.code, &facts), | |
| 241 | 285 | closed: diagnosis::closed(&facts), | |
| 242 | − | running: matches!(now.code, Code::Idle | Code::Busy | Code::Offline), | |
| 286 | + | running: matches!(now.code, Code::Idle | Code::Busy | Code::Offline) || facts.sandbox.as_ref().is_some_and(Status::running), | |
| 243 | 287 | version: now.version, | |
| 244 | 288 | backend: settings.runner_backend, | |
| 245 | 289 | link: facts.workspace.as_ref().and_then(|w| server.join(&format!("{w}/-/runners")).ok()).map(|u| u.to_string()), | |
| ⋯ | |||
| 366 | 410 | app.opener().open_path(path.to_string_lossy(), None::<&str>).map_err(|e| e.to_string())?; | |
| 367 | 411 | } | |
| 368 | 412 | "open_docker" => open_docker(app)?, | |
| 413 | + | "copy_wsl_install" => copy(launcher::WSL_INSTALL)?, | |
| 414 | + | "copy_vfkit_install" => copy(launcher::VFKIT_INSTALL)?, | |
| 415 | + | "windows_features" => windows_features()?, | |
| 369 | 416 | "get_docker" => browse("https://docs.docker.com/get-started/get-docker/")?, | |
| 370 | 417 | "install_sysbox" => browse("https://github.com/nestybox/sysbox#installation")?, | |
| 371 | − | "learn_more" => browse("https://docs.g1t.sh/guides/desktop/#the-runner")?, | |
| 418 | + | "learn_more" => browse("https://docs.g1t.sh/guides/desktop/#how-jobs-run")?, | |
| 372 | 419 | "report" => browse(config::server(app).join("support").map_err(|e| e.to_string())?.as_str())?, | |
| 373 | 420 | other => return Err(format!("the runner has no action called {other}")), | |
| 374 | 421 | } | |
| ⋯ | |||
| 392 | 439 | Err("Start Docker with your system's service manager, such as `sudo systemctl start docker`.".into()) | |
| 393 | 440 | } | |
| 394 | 441 | ||
| 442 | + | /// Puts a command on the clipboard, for the person to paste where it runs. | |
| 443 | + | fn copy(text: &str) -> Result<(), String> { | |
| 444 | + | let tools: &[(&str, &[&str])] = if cfg!(windows) { | |
| 445 | + | &[("clip", &[])] | |
| 446 | + | } else if cfg!(target_os = "macos") { | |
| 447 | + | &[("pbcopy", &[])] | |
| 448 | + | } else { | |
| 449 | + | &[("wl-copy", &[]), ("xclip", &["-selection", "clipboard"]), ("xsel", &["--clipboard", "--input"])] | |
| 450 | + | }; | |
| 451 | + | for (program, args) in tools { | |
| 452 | + | let Ok(mut child) = backend::quiet(program).args(*args).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else { continue }; | |
| 453 | + | if let Some(mut input) = child.stdin.take() { | |
| 454 | + | let _ = input.write_all(text.as_bytes()); | |
| 455 | + | } | |
| 456 | + | if child.wait().is_ok_and(|status| status.success()) { | |
| 457 | + | return Ok(()); | |
| 458 | + | } | |
| 459 | + | } | |
| 460 | + | Err(format!("It couldn't be copied. The command is: {text}")) | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | /// Windows' "Turn Windows features on or off", where Windows Sandbox and | |
| 464 | + | /// Virtual Machine Platform are. | |
| 465 | + | fn windows_features() -> Result<(), String> { | |
| 466 | + | if !cfg!(windows) { | |
| 467 | + | return Err("Windows features are only on Windows.".into()); | |
| 468 | + | } | |
| 469 | + | backend::quiet("optionalfeatures.exe").spawn().map(|_| ()).map_err(|e| format!("Windows features couldn't be opened: {e}")) | |
| 470 | + | } | |
| 471 | + | ||
| 395 | 472 | /// The workspace a token is wanted for now, if one is: the site asks as it loads. | |
| 396 | 473 | pub fn wanted(app: &AppHandle) -> Option<String> { | |
| 397 | 474 | app.state::<Shared>().wanted.lock().unwrap().clone() | |
| ⋯ | |||
| 415 | 492 | ||
| 416 | 493 | /// How the runner is started this time. | |
| 417 | 494 | enum Process { | |
| 418 | − | /// `g1t-runner` or its sandbox launcher, a child of the app. | |
| 495 | + | /// `g1t-runner`, a child of the app, whose output is the runner's log. | |
| 419 | 496 | Child(Child), | |
| 420 | − | /// The runner's container, and `docker logs --follow` on it. | |
| 421 | − | Container(Child), | |
| 497 | + | /// The sandbox, which runs on its own once `up` is done; the launcher | |
| 498 | + | /// command the app is waiting for, while there is one. | |
| 499 | + | Sandbox(Option<(Step, Child)>), | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | /// A launcher command that takes a while. | |
| 503 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 504 | + | enum Step { | |
| 505 | + | Up, | |
| 506 | + | Update, | |
| 422 | 507 | } | |
| 423 | 508 | ||
| 509 | + | impl Step { | |
| 510 | + | fn name(self) -> &'static str { | |
| 511 | + | match self { | |
| 512 | + | Step::Up => "up", | |
| 513 | + | Step::Update => "update", | |
| 514 | + | } | |
| 515 | + | } | |
| 516 | + | } | |
| 517 | + | ||
| 424 | 518 | struct Run { | |
| 425 | 519 | generation: u64, | |
| 426 | 520 | engine: Engine, | |
| ⋯ | |||
| 437 | 531 | forgotten: bool, | |
| 438 | 532 | token_refused: bool, | |
| 439 | 533 | error: Option<String>, | |
| 534 | + | /// The status a launcher command is printing, line by line. | |
| 535 | + | json: Option<Vec<String>>, | |
| 536 | + | /// Asking the sandbox how it is, and when it was last asked. | |
| 537 | + | polling: bool, | |
| 538 | + | polled_at: Option<Instant>, | |
| 539 | + | /// A poll asked before a launcher command ended, whose answer is old. | |
| 540 | + | stale: bool, | |
| 440 | 541 | } | |
| 441 | 542 | ||
| 543 | + | impl Run { | |
| 544 | + | fn new(generation: u64, engine: Engine, backend: Backend, workspace: String, process: Process, with_token: bool) -> Run { | |
| 545 | + | Run { | |
| 546 | + | generation, | |
| 547 | + | engine, | |
| 548 | + | backend, | |
| 549 | + | workspace, | |
| 550 | + | process, | |
| 551 | + | listening: false, | |
| 552 | + | busy: false, | |
| 553 | + | offline_at: None, | |
| 554 | + | with_token, | |
| 555 | + | forgotten: false, | |
| 556 | + | token_refused: false, | |
| 557 | + | error: None, | |
| 558 | + | json: None, | |
| 559 | + | polling: false, | |
| 560 | + | polled_at: None, | |
| 561 | + | stale: false, | |
| 562 | + | } | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | fn step(&self) -> Option<Step> { | |
| 566 | + | match &self.process { | |
| 567 | + | Process::Sandbox(Some((step, _))) => Some(*step), | |
| 568 | + | _ => None, | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | } | |
| 572 | + | ||
| 442 | 573 | struct Keeper { | |
| 443 | 574 | app: AppHandle, | |
| 444 | 575 | tx: Sender<Ask>, | |
| ⋯ | |||
| 449 | 580 | failures: u32, | |
| 450 | 581 | next_update: Instant, | |
| 451 | 582 | log: Option<File>, | |
| 583 | + | /// The runner was updated: the sandbox takes its image before it starts again. | |
| 584 | + | image_pending: bool, | |
| 585 | + | /// Why the last update failed, said until the next one works. | |
| 586 | + | update_failed: Option<String>, | |
| 452 | 587 | } | |
| 453 | 588 | ||
| 454 | 589 | /// What a set of the state carries besides its code. | |
| ⋯ | |||
| 457 | 592 | detail: Option<String>, | |
| 458 | 593 | exit_code: Option<i32>, | |
| 459 | 594 | wait_seconds: Option<u64>, | |
| 595 | + | fix: Option<String>, | |
| 460 | 596 | } | |
| 461 | 597 | ||
| 462 | 598 | fn detail(text: impl Into<String>) -> Extra { | |
| ⋯ | |||
| 466 | 602 | impl Keeper { | |
| 467 | 603 | fn new(app: AppHandle, tx: Sender<Ask>) -> Keeper { | |
| 468 | 604 | let now = Instant::now(); | |
| 469 | − | Keeper { app, tx, run: None, generation: 0, token: None, not_before: now + AFTER_START, failures: 0, next_update: now + FIRST_UPDATE, log: None } | |
| 605 | + | Keeper { app, tx, run: None, generation: 0, token: None, not_before: now + AFTER_START, failures: 0, next_update: now + FIRST_UPDATE, log: None, image_pending: false, update_failed: None } | |
| 470 | 606 | } | |
| 471 | 607 | ||
| 472 | 608 | fn keep(mut self, rx: Receiver<Ask>) { | |
| ⋯ | |||
| 495 | 631 | let shared = self.app.state::<Shared>(); | |
| 496 | 632 | let mut now = shared.now.lock().unwrap(); | |
| 497 | 633 | let job = if code == Code::Busy { now.job.clone() } else { None }; | |
| 498 | − | let next = Now { code, detail: extra.detail, exit_code: extra.exit_code, wait_seconds: extra.wait_seconds, job, version: now.version.clone() }; | |
| 634 | + | let next = Now { | |
| 635 | + | code, | |
| 636 | + | detail: extra.detail, | |
| 637 | + | exit_code: extra.exit_code, | |
| 638 | + | wait_seconds: extra.wait_seconds, | |
| 639 | + | job, | |
| 640 | + | version: now.version.clone(), | |
| 641 | + | fix: extra.fix, | |
| 642 | + | sandbox: now.sandbox.clone(), | |
| 643 | + | }; | |
| 499 | 644 | let changed = *now != next; | |
| 500 | 645 | *now = next; | |
| 501 | 646 | changed | |
| ⋯ | |||
| 551 | 696 | self.failures = 0; | |
| 552 | 697 | match self.code() { | |
| 553 | 698 | // The runner is fine: only the update is tried again. | |
| 554 | − | Code::UpdateFailed => self.next_update = Instant::now(), | |
| 699 | + | Code::UpdateFailed => { | |
| 700 | + | self.next_update = Instant::now(); | |
| 701 | + | self.update_failed = None; | |
| 702 | + | } | |
| 555 | 703 | Code::Offline => self.halt(), | |
| 556 | 704 | _ if self.run.as_ref().is_some_and(|run| !run.listening) => self.halt(), | |
| 557 | 705 | _ => {} | |
| ⋯ | |||
| 581 | 729 | self.set(if allowed { Code::RegisterFailed } else { Code::NotAllowed }, detail(why)); | |
| 582 | 730 | } | |
| 583 | 731 | } | |
| 584 | − | Ask::Line(generation, line) => self.line(generation, &line), | |
| 732 | + | Ask::Line(generation, line, out) => self.line(generation, &line, out), | |
| 585 | 733 | Ask::Exited(generation) => self.exited(generation), | |
| 734 | + | Ask::Sandbox(generation, status) => self.polled(generation, status), | |
| 586 | 735 | } | |
| 587 | 736 | } | |
| 588 | 737 | ||
| 589 | − | fn line(&mut self, generation: u64, line: &str) { | |
| 738 | + | fn line(&mut self, generation: u64, line: &str, out: bool) { | |
| 590 | 739 | let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return }; | |
| 740 | + | if run.engine == Engine::Launcher { | |
| 741 | + | // The launcher's log, then its status, which is read when it ends. | |
| 742 | + | if out && (run.json.is_some() || launcher::starts_status(line)) { | |
| 743 | + | run.json.get_or_insert_with(Vec::new).push(line.to_owned()); | |
| 744 | + | } else { | |
| 745 | + | self.write_log(line); | |
| 746 | + | } | |
| 747 | + | return; | |
| 748 | + | } | |
| 591 | 749 | let mut became = None; | |
| 592 | 750 | let mut job = None; | |
| 593 | 751 | let mut version = None; | |
| ⋯ | |||
| 623 | 781 | workspace: run.workspace.clone(), | |
| 624 | 782 | backend: run.backend, | |
| 625 | 783 | }); | |
| 626 | − | // A container started with a token keeps it in its configuration: | |
| 627 | − | // it is replaced at once by one without, which needs no token. | |
| 628 | − | let replace = registered.is_some() && run.with_token && matches!(run.process, Process::Container(_)); | |
| 629 | 784 | self.write_log(line); | |
| 630 | 785 | if let Some(registration) = registered { | |
| 631 | 786 | registration.save(&self.app); | |
| ⋯ | |||
| 634 | 789 | self.note(job, version); | |
| 635 | 790 | if let Some(code) = became { | |
| 636 | 791 | self.set(code, Extra::default()); | |
| 637 | − | } | |
| 638 | − | if replace { | |
| 639 | − | self.halt(); | |
| 640 | − | self.not_before = Instant::now(); | |
| 641 | 792 | } | |
| 642 | 793 | } | |
| 643 | 794 | ||
| ⋯ | |||
| 645 | 796 | if self.run.as_ref().is_none_or(|run| run.generation != generation) { | |
| 646 | 797 | return; | |
| 647 | 798 | } | |
| 799 | + | if self.run.as_ref().is_some_and(|run| run.engine == Engine::Launcher) { | |
| 800 | + | return self.launched(generation); | |
| 801 | + | } | |
| 648 | 802 | let mut run = self.run.take().expect("checked above"); | |
| 649 | 803 | let exit_code = match &mut run.process { | |
| 650 | 804 | Process::Child(child) => child.wait().ok().and_then(|status| status.code()), | |
| 651 | − | Process::Container(_) => container::exit_code(), | |
| 805 | + | Process::Sandbox(_) => None, | |
| 652 | 806 | }; | |
| 653 | − | self.stop(&mut run); | |
| 807 | + | self.stop(&mut run, Down::Now); | |
| 654 | 808 | if run.forgotten || run.token_refused { | |
| 655 | 809 | // The registration is no more, or was never made: the next start asks for a token. | |
| 656 | 810 | self.forget(); | |
| ⋯ | |||
| 666 | 820 | } else if !run.listening && Registration::load(&self.app).is_none() { | |
| 667 | 821 | self.set(Code::RegisterFailed, Extra { detail: run.error, ..Extra::default() }); | |
| 668 | 822 | } else { | |
| 669 | − | self.set(Code::Crashed, Extra { detail: run.error, exit_code, wait_seconds: Some(wait.as_secs()) }); | |
| 823 | + | self.set(Code::Crashed, Extra { detail: run.error, exit_code, wait_seconds: Some(wait.as_secs()), ..Extra::default() }); | |
| 670 | 824 | } | |
| 671 | 825 | } | |
| 672 | 826 | ||
| 673 | − | /// Stops a run's process, the way it was started. | |
| 674 | − | fn stop(&self, run: &mut Run) { | |
| 827 | + | /// Stops a run's process, the way it was started: the sandbox `how` says. | |
| 828 | + | fn stop(&mut self, run: &mut Run, how: Down) { | |
| 675 | 829 | match (&mut run.process, run.engine) { | |
| 676 | − | (Process::Container(logs), _) => container::stop(Some(logs)), | |
| 677 | − | (Process::Child(child), Engine::Launcher) => { | |
| 678 | − | launcher::down(&native::binary(&folder(&self.app)), &native_dir(&self.app, run.backend)); | |
| 679 | − | native::stop(child); | |
| 830 | + | (Process::Sandbox(step), _) => { | |
| 831 | + | // A launcher command still going is ended first, then the sandbox stopped. | |
| 832 | + | if let Some((_, child)) = step.as_mut() { | |
| 833 | + | native::stop(child); | |
| 834 | + | } | |
| 835 | + | let binary = native::binary(&folder(&self.app)); | |
| 836 | + | if let Err(why) = launcher::down(&binary, &native_dir(&self.app, Backend::Sandboxed), how) { | |
| 837 | + | self.write_log(&format!("g1t: the sandbox didn't stop cleanly: {why}")); | |
| 838 | + | } | |
| 839 | + | self.app.state::<Shared>().now.lock().unwrap().sandbox = None; | |
| 680 | 840 | } | |
| 681 | − | // The image's runner is always a container; a child here is only ever g1t-runner's. | |
| 682 | − | (Process::Child(child), Engine::Sysbox) => native::stop(child), | |
| 841 | + | (Process::Child(child), Engine::Launcher) => native::stop(child), | |
| 683 | 842 | (Process::Child(child), Engine::Native { docker }) => { | |
| 684 | 843 | native::stop(child); | |
| 685 | 844 | // A job's container outlives the runner killed under it: removed now, not at its next start. | |
| ⋯ | |||
| 693 | 852 | ||
| 694 | 853 | /// Stops whatever runs now. | |
| 695 | 854 | fn halt(&mut self) { | |
| 855 | + | self.halt_as(Down::Now); | |
| 856 | + | } | |
| 857 | + | ||
| 858 | + | /// Stops whatever runs: the sandbox now, after its job, or for good. | |
| 859 | + | fn halt_as(&mut self, how: Down) { | |
| 696 | 860 | self.generation += 1; | |
| 697 | 861 | if let Some(mut run) = self.run.take() { | |
| 698 | − | self.stop(&mut run); | |
| 862 | + | self.stop(&mut run, how); | |
| 699 | 863 | } | |
| 700 | 864 | } | |
| 701 | 865 | ||
| ⋯ | |||
| 713 | 877 | let Some(registration) = Registration::load(&app) else { return }; | |
| 714 | 878 | let binary = native::binary(&folder(&app)); | |
| 715 | 879 | match registration.backend { | |
| 716 | − | Backend::Sandboxed if launcher::READY => { | |
| 717 | − | launcher::down(&binary, &native_dir(&app, Backend::Sandboxed)); | |
| 718 | − | let _ = std::fs::remove_dir_all(native_dir(&app, Backend::Sandboxed)); | |
| 719 | − | } | |
| 880 | + | // The sandbox keeps its registration for when it is chosen | |
| 881 | + | // again (sandbox-registration.json says whose): it only stops, | |
| 882 | + | // after its job. | |
| 720 | 883 | Backend::Sandboxed => { | |
| 721 | − | if backend::docker_ready() { | |
| 722 | − | container::forget(); | |
| 723 | − | } | |
| 884 | + | let _ = launcher::down(&binary, &native_dir(&app, Backend::Sandboxed), Down::Drain); | |
| 724 | 885 | } | |
| 725 | 886 | backend => native::forget(&binary, &native_dir(&app, backend)), | |
| 726 | 887 | } | |
| 727 | 888 | Registration::clear(&app); | |
| 728 | 889 | } | |
| 729 | 890 | ||
| 730 | − | /// Whether what the runner keeps of a registration is still here. | |
| 731 | − | fn kept(&self, engine: Engine, backend: Backend) -> bool { | |
| 732 | − | match engine { | |
| 733 | − | Engine::Sysbox => container::registered(), | |
| 734 | − | Engine::Launcher => native_dir(&self.app, backend).exists(), | |
| 735 | − | Engine::Native { .. } => native_dir(&self.app, backend).join("config.json").exists(), | |
| 891 | + | /// Removes the sandbox and its registration: it was for another | |
| 892 | + | /// workspace, or g1t no longer knows its runners. | |
| 893 | + | fn forget_sandbox(&mut self) { | |
| 894 | + | let binary = native::binary(&folder(&self.app)); | |
| 895 | + | if let Err(why) = launcher::down(&binary, &native_dir(&self.app, Backend::Sandboxed), Down::Delete) { | |
| 896 | + | self.write_log(&format!("g1t: the sandbox couldn't be removed: {why}")); | |
| 897 | + | } | |
| 898 | + | SandboxRegistration::clear(&self.app); | |
| 899 | + | if Registration::load(&self.app).is_some_and(|r| r.backend == Backend::Sandboxed) { | |
| 900 | + | Registration::clear(&self.app); | |
| 736 | 901 | } | |
| 737 | 902 | } | |
| 738 | 903 | ||
| 904 | + | /// Whether what a native runner keeps of a registration is still here. | |
| 905 | + | fn kept(&self, backend: Backend) -> bool { | |
| 906 | + | native_dir(&self.app, backend).join("config.json").exists() | |
| 907 | + | } | |
| 908 | + | ||
| 739 | 909 | /// Asks the site for a registration token for `workspace`. | |
| 740 | 910 | fn want(&mut self, workspace: &str) { | |
| 741 | 911 | *self.app.state::<Shared>().wanted.lock().unwrap() = Some(workspace.to_owned()); | |
| ⋯ | |||
| 761 | 931 | if let Some(run) = &self.run { | |
| 762 | 932 | let moved = run.backend != settings.runner_backend || Some(&run.workspace) != workspace.as_ref(); | |
| 763 | 933 | let back = run.offline_at.is_some_and(|at| at.elapsed() >= BACK_ONLINE); | |
| 764 | − | let (busy, listening) = (run.busy, run.listening); | |
| 934 | + | let (busy, listening, stepping) = (run.busy, run.listening, run.step().is_some()); | |
| 765 | 935 | if back { | |
| 766 | 936 | if let Some(run) = self.run.as_mut() { | |
| 767 | 937 | run.offline_at = None; | |
| ⋯ | |||
| 769 | 939 | self.set(Code::Idle, Extra::default()); | |
| 770 | 940 | } | |
| 771 | 941 | // A change of backend or workspace waits for the job to end, as an update does. | |
| 772 | − | if moved && !busy { | |
| 773 | − | self.halt(); | |
| 942 | + | if moved && !busy && !stepping { | |
| 943 | + | self.halt_as(Down::Drain); | |
| 774 | 944 | self.not_before = Instant::now(); | |
| 775 | − | } else if !busy && listening && Instant::now() >= self.next_update { | |
| 945 | + | } else if !busy && listening && !stepping && Instant::now() >= self.next_update { | |
| 776 | 946 | self.update(); | |
| 777 | 947 | } | |
| 948 | + | self.poll(); | |
| 778 | 949 | return; | |
| 779 | 950 | } | |
| 780 | 951 | if Instant::now() < self.not_before { | |
| ⋯ | |||
| 791 | 962 | self.not_before = Instant::now() + LOOK_AGAIN; | |
| 792 | 963 | let code = match missing { | |
| 793 | 964 | Missing::Docker => Code::NoDocker, | |
| 794 | − | Missing::Sandbox => Code::NoSandbox, | |
| 795 | − | Missing::Sysbox => Code::NoSysbox, | |
| 796 | 965 | }; | |
| 797 | 966 | self.set(code, Extra::default()); | |
| 798 | 967 | return; | |
| ⋯ | |||
| 808 | 977 | self.set(code, detail("Waiting for g1t to open.")); | |
| 809 | 978 | return; | |
| 810 | 979 | }; | |
| 980 | + | if engine == Engine::Launcher { | |
| 981 | + | return self.start_sandbox(workspace); | |
| 982 | + | } | |
| 811 | 983 | let server = config::server(&app); | |
| 812 | 984 | let registration = Registration::load(&app); | |
| 813 | − | let ours = registration.as_ref().is_some_and(|r| r.server == server.as_str() && r.workspace == workspace && r.backend == backend) && self.kept(engine, backend); | |
| 985 | + | let ours = registration.as_ref().is_some_and(|r| r.server == server.as_str() && r.workspace == workspace && r.backend == backend) && self.kept(backend); | |
| 814 | 986 | let mut token = None; | |
| 815 | 987 | if !ours { | |
| 816 | 988 | if registration.is_some() { | |
| ⋯ | |||
| 822 | 994 | _ => return self.want(&workspace), | |
| 823 | 995 | } | |
| 824 | 996 | } | |
| 825 | − | // The setting names the workspace from now on, so browsing another one never moves the runner. | |
| 826 | − | if config::settings(&app).runner_workspace.as_deref() != Some(workspace.as_str()) { | |
| 827 | − | let mut settings = config::settings(&app); | |
| 828 | − | settings.runner_workspace = Some(workspace.clone()); | |
| 829 | − | let _ = config::set_settings(&app, settings); | |
| 830 | − | } | |
| 997 | + | self.hold_workspace(&workspace); | |
| 831 | 998 | self.set(Code::Starting, Extra::default()); | |
| 832 | 999 | let envs = environment(&server, backend, token.as_deref()); | |
| 833 | 1000 | let generation = self.generation + 1; | |
| 834 | 1001 | self.generation = generation; | |
| 835 | − | let started = match engine { | |
| 836 | − | Engine::Sysbox => { | |
| 837 | − | if !container::have_image() { | |
| 838 | − | self.set(Code::Updating, detail("Downloading the runner's image.")); | |
| 839 | − | container::pull(); | |
| 840 | − | } | |
| 841 | − | container::start(&envs).map(Process::Container) | |
| 842 | − | } | |
| 843 | − | Engine::Launcher | Engine::Native { .. } => self.ensure_binary(&server).and_then(|binary| { | |
| 844 | − | let dir = native_dir(&app, backend); | |
| 845 | − | let child = if engine == Engine::Launcher { | |
| 846 | − | let mut command = launcher::up(&binary, &dir); | |
| 847 | − | command.envs(envs.iter().map(|(name, value)| (*name, value.as_str()))); | |
| 848 | − | command.spawn() | |
| 849 | − | } else { | |
| 850 | − | native::spawn(&binary, &dir, &envs) | |
| 851 | − | }; | |
| 852 | − | let child = child.map_err(|e| format!("g1t-runner could not start: {e}"))?; | |
| 853 | − | let _ = std::fs::write(folder(&app).join("pid"), child.id().to_string()); | |
| 854 | − | Ok(Process::Child(child)) | |
| 855 | − | }), | |
| 856 | − | }; | |
| 1002 | + | let started = self.ensure_binary(&server).and_then(|binary| { | |
| 1003 | + | let child = native::spawn(&binary, &native_dir(&app, backend), &envs).map_err(|e| format!("g1t-runner could not start: {e}"))?; | |
| 1004 | + | let _ = std::fs::write(folder(&app).join("pid"), child.id().to_string()); | |
| 1005 | + | Ok(child) | |
| 1006 | + | }); | |
| 857 | 1007 | match started { | |
| 858 | − | Ok(mut process) => { | |
| 859 | − | let child = match &mut process { | |
| 860 | − | Process::Child(child) | Process::Container(child) => child, | |
| 861 | − | }; | |
| 862 | − | self.follow(generation, child); | |
| 863 | − | self.run = Some(Run { | |
| 864 | − | generation, | |
| 865 | − | engine, | |
| 866 | − | backend, | |
| 867 | − | workspace, | |
| 868 | − | process, | |
| 869 | − | listening: false, | |
| 870 | − | busy: false, | |
| 871 | − | offline_at: None, | |
| 872 | − | with_token: token.is_some(), | |
| 873 | − | forgotten: false, | |
| 874 | − | token_refused: false, | |
| 875 | − | error: None, | |
| 876 | − | }); | |
| 1008 | + | Ok(mut child) => { | |
| 1009 | + | self.follow(generation, &mut child); | |
| 1010 | + | self.run = Some(Run::new(generation, engine, backend, workspace, Process::Child(child), token.is_some())); | |
| 877 | 1011 | self.set(Code::Starting, Extra::default()); | |
| 878 | 1012 | } | |
| 879 | − | Err(why) => { | |
| 880 | − | // The token is kept for the next try, while it lasts. | |
| 881 | − | if let Some(given) = token { | |
| 882 | − | self.token = Some((workspace, given)); | |
| 883 | − | } | |
| 884 | − | self.failures += 1; | |
| 885 | − | self.not_before = Instant::now() + LOOK_AGAIN; | |
| 886 | − | let offline = why.contains("error sending request") || why.contains("dns") || why.contains("connect"); | |
| 887 | − | self.write_log(&format!("g1t: the runner could not start: {why}")); | |
| 888 | − | if offline { | |
| 889 | − | self.set(Code::Offline, Extra::default()); | |
| 890 | − | } else { | |
| 891 | − | self.set(Code::Crashed, Extra { detail: Some(why), wait_seconds: Some(LOOK_AGAIN.as_secs()), ..Extra::default() }); | |
| 892 | − | } | |
| 893 | − | } | |
| 1013 | + | Err(why) => self.not_started(workspace, token, why), | |
| 1014 | + | } | |
| 1015 | + | } | |
| 1016 | + | ||
| 1017 | + | /// The runner couldn't be started: the token is kept for the next try, | |
| 1018 | + | /// while it lasts, and it is tried again in a while. | |
| 1019 | + | fn not_started(&mut self, workspace: String, token: Option<String>, why: String) { | |
| 1020 | + | if let Some(given) = token { | |
| 1021 | + | self.token = Some((workspace, given)); | |
| 1022 | + | } | |
| 1023 | + | self.failures += 1; | |
| 1024 | + | self.not_before = Instant::now() + LOOK_AGAIN; | |
| 1025 | + | let offline = why.contains("error sending request") || why.contains("dns") || why.contains("connect"); | |
| 1026 | + | self.write_log(&format!("g1t: the runner could not start: {why}")); | |
| 1027 | + | if offline { | |
| 1028 | + | self.set(Code::Offline, Extra::default()); | |
| 1029 | + | } else { | |
| 1030 | + | self.set(Code::Crashed, Extra { detail: Some(why), wait_seconds: Some(LOOK_AGAIN.as_secs()), ..Extra::default() }); | |
| 894 | 1031 | } | |
| 895 | 1032 | } | |
| 896 | 1033 | ||
| 1034 | + | /// The setting names the workspace from now on, so browsing another one never moves the runner. | |
| 1035 | + | fn hold_workspace(&self, workspace: &str) { | |
| 1036 | + | if config::settings(&self.app).runner_workspace.as_deref() != Some(workspace) { | |
| 1037 | + | let mut settings = config::settings(&self.app); | |
| 1038 | + | settings.runner_workspace = Some(workspace.to_owned()); | |
| 1039 | + | let _ = config::set_settings(&self.app, settings); | |
| 1040 | + | } | |
| 1041 | + | } | |
| 1042 | + | ||
| 897 | 1043 | /// Reads the child's output, a line at a time, onto the thread's channel. | |
| 898 | 1044 | fn follow(&self, generation: u64, child: &mut Child) { | |
| 899 | 1045 | fn pipe(tx: Sender<Ask>, generation: u64, from: impl Read + Send + 'static, last: bool) { | |
| 900 | 1046 | std::thread::spawn(move || { | |
| 901 | 1047 | for line in BufReader::new(from).lines().map_while(Result::ok) { | |
| 902 | − | if tx.send(Ask::Line(generation, line)).is_err() { | |
| 1048 | + | if tx.send(Ask::Line(generation, line, last)).is_err() { | |
| 903 | 1049 | return; | |
| 904 | 1050 | } | |
| 905 | 1051 | } | |
| ⋯ | |||
| 940 | 1086 | return; | |
| 941 | 1087 | } | |
| 942 | 1088 | let Some(engine) = self.run.as_ref().map(|run| run.engine) else { return }; | |
| 943 | − | match engine { | |
| 944 | − | Engine::Sysbox => { | |
| 945 | − | if container::pull() || container::outdated() { | |
| 946 | − | self.set(Code::Updating, detail("Starting the newest runner image.")); | |
| 947 | − | self.halt(); | |
| 948 | − | self.not_before = Instant::now(); | |
| 949 | − | } | |
| 1089 | + | let server = config::server(&self.app); | |
| 1090 | + | let folder = folder(&self.app); | |
| 1091 | + | let release = match self.newer_release(&server) { | |
| 1092 | + | Ok(release) => release, | |
| 1093 | + | Err(why) => { | |
| 1094 | + | let why = format!("g1t-runner couldn't be updated: {why}"); | |
| 1095 | + | self.write_log(&format!("g1t: {why}")); | |
| 1096 | + | self.update_failed = Some(why.clone()); | |
| 1097 | + | return self.set(Code::UpdateFailed, detail(why)); | |
| 1098 | + | } | |
| 1099 | + | }; | |
| 1100 | + | let Some((version, bytes)) = release else { | |
| 1101 | + | // The program is the newest; the sandbox's image may not be | |
| 1102 | + | // (`update` does nothing when it is). | |
| 1103 | + | if engine == Engine::Launcher { | |
| 1104 | + | self.sandbox_step(Step::Update); | |
| 1105 | + | } | |
| 1106 | + | return; | |
| 1107 | + | }; | |
| 1108 | + | self.set(Code::Updating, detail(format!("Updating g1t-runner to {version}."))); | |
| 1109 | + | // The sandbox's runner for Windows jobs is this program too: it | |
| 1110 | + | // stops, after its job, before the program is replaced. | |
| 1111 | + | self.halt_as(Down::Drain); | |
| 1112 | + | match native::put(&folder, &bytes, &version) { | |
| 1113 | + | Ok(()) => { | |
| 1114 | + | self.note(None, Some(version)); | |
| 1115 | + | self.update_failed = None; | |
| 1116 | + | // The sandbox's image comes with each release. | |
| 1117 | + | self.image_pending = engine == Engine::Launcher; | |
| 1118 | + | } | |
| 1119 | + | Err(why) => self.write_log(&format!("g1t: g1t-runner {version} couldn't be put in place: {why}")), | |
| 1120 | + | } | |
| 1121 | + | self.not_before = Instant::now(); | |
| 1122 | + | } | |
| 1123 | + | ||
| 1124 | + | /// A release newer than the `g1t-runner` here, downloaded and checked | |
| 1125 | + | /// against its SHA-256: its version and program. None when this one is | |
| 1126 | + | /// the newest, or the releases can't be asked now. | |
| 1127 | + | fn newer_release(&self, server: &Url) -> Result<Option<(String, Vec<u8>)>, String> { | |
| 1128 | + | let Ok(manifest) = native::latest(server) else { return Ok(None) }; | |
| 1129 | + | if !native::installed(&folder(&self.app)).is_none_or(|have| native::newer(&manifest.version, &have)) { | |
| 1130 | + | return Ok(None); | |
| 1131 | + | } | |
| 1132 | + | let bytes = native::download(server, &manifest).map_err(|why| format!("{} didn't download: {why}", manifest.version))?; | |
| 1133 | + | Ok(Some((manifest.version, bytes))) | |
| 1134 | + | } | |
| 1135 | + | ||
| 1136 | + | // --- The sandbox (launcher.rs) ------------------------------------------------ | |
| 1137 | + | ||
| 1138 | + | /// Starts the sandbox for `workspace`: registered with it the first | |
| 1139 | + | /// time, with a token from the site; from its own registration after. | |
| 1140 | + | fn start_sandbox(&mut self, workspace: String) { | |
| 1141 | + | let app = self.app.clone(); | |
| 1142 | + | let server = config::server(&app); | |
| 1143 | + | let binary = match self.ensure_binary(&server) { | |
| 1144 | + | Ok(binary) => binary, | |
| 1145 | + | Err(why) => return self.not_started(workspace, None, why), | |
| 1146 | + | }; | |
| 1147 | + | let dir = native_dir(&app, Backend::Sandboxed); | |
| 1148 | + | let mut status = match launcher::status(&binary, &dir) { | |
| 1149 | + | Ok(status) => status, | |
| 1150 | + | Err(why) => return self.no_status(why), | |
| 1151 | + | }; | |
| 1152 | + | self.seen(&status); | |
| 1153 | + | // Nothing here can be sandboxed: why, and how to fix it; looked at again in a while. | |
| 1154 | + | if !status.anything_available() { | |
| 1155 | + | self.not_before = Instant::now() + LOOK_AGAIN; | |
| 1156 | + | let (code, extra) = shown(&status); | |
| 1157 | + | return self.set(code, extra); | |
| 1158 | + | } | |
| 1159 | + | // Another backend's registration goes; the sandbox's own is kept for its workspace. | |
| 1160 | + | if Registration::load(&app).is_some_and(|r| r.backend != Backend::Sandboxed) { | |
| 1161 | + | self.set(Code::Starting, detail("Removing this computer's earlier registration.")); | |
| 1162 | + | self.forget(); | |
| 1163 | + | } | |
| 1164 | + | let kept = SandboxRegistration::load(&app); | |
| 1165 | + | let ours = kept.as_ref().is_some_and(|k| k.server == server.as_str() && k.workspace == workspace); | |
| 1166 | + | let made = status.capabilities.iter().any(|c| c.state != State::Absent) || status.state != State::Absent; | |
| 1167 | + | if !ours && made && kept.is_none() { | |
| 1168 | + | // Made by hand (`g1t-runner sandbox up`), and there is one per computer: it is the person's to remove. | |
| 1169 | + | self.not_before = Instant::now() + LOOK_AGAIN; | |
| 1170 | + | return self.set(Code::SandboxTaken, Extra::default()); | |
| 1171 | + | } | |
| 1172 | + | if !ours && made { | |
| 1173 | + | // The app's sandbox, registered with another workspace or server. | |
| 1174 | + | self.set(Code::Starting, detail("Removing this computer's sandbox for another workspace.")); | |
| 1175 | + | self.forget_sandbox(); | |
| 1176 | + | status = match launcher::status(&binary, &dir) { | |
| 1177 | + | Ok(status) => status, | |
| 1178 | + | Err(why) => return self.no_status(why), | |
| 1179 | + | }; | |
| 1180 | + | } | |
| 1181 | + | let mut token = None; | |
| 1182 | + | if !ours || !kept.as_ref().is_some_and(|k| k.registered) || status.unregistered() { | |
| 1183 | + | match self.token.take() { | |
| 1184 | + | Some((for_workspace, given)) if for_workspace == workspace => token = Some(given), | |
| 1185 | + | _ => return self.want(&workspace), | |
| 1186 | + | } | |
| 1187 | + | SandboxRegistration { server: server.to_string(), workspace: workspace.clone(), registered: false }.save(&app); | |
| 1188 | + | } | |
| 1189 | + | self.hold_workspace(&workspace); | |
| 1190 | + | let generation = self.generation + 1; | |
| 1191 | + | self.generation = generation; | |
| 1192 | + | self.run = Some(Run::new(generation, Engine::Launcher, Backend::Sandboxed, workspace.clone(), Process::Sandbox(None), token.is_some())); | |
| 1193 | + | // A new release's image first, then the sandbox starts again. | |
| 1194 | + | let step = if self.image_pending && status.linux().state != State::Absent { Step::Update } else { Step::Up }; | |
| 1195 | + | self.image_pending = false; | |
| 1196 | + | let name = backend::machine_name(); | |
| 1197 | + | let url = server.as_str().trim_end_matches('/').to_owned(); | |
| 1198 | + | let up = launcher::Up { url: &url, name: &name, labels: "desktop", token: token.as_deref() }; | |
| 1199 | + | let args = match step { | |
| 1200 | + | Step::Up => launcher::up_args(&dir, &up), | |
| 1201 | + | Step::Update => launcher::update_args(&dir), | |
| 1202 | + | }; | |
| 1203 | + | if let Err(why) = self.spawn_step(step, args) { | |
| 1204 | + | self.run = None; | |
| 1205 | + | return self.not_started(workspace, token, why); | |
| 1206 | + | } | |
| 1207 | + | self.set(Code::Starting, detail(STARTING_SANDBOX)); | |
| 1208 | + | } | |
| 1209 | + | ||
| 1210 | + | /// `sandbox status` didn't answer: a `g1t-runner` from before its | |
| 1211 | + | /// launcher, which a newer release replaces, or one that failed. | |
| 1212 | + | fn no_status(&mut self, why: String) { | |
| 1213 | + | self.write_log(&format!("g1t: the sandbox's status: {why}")); | |
| 1214 | + | self.failures += 1; | |
| 1215 | + | if why.contains("older than its sandbox launcher") { | |
| 1216 | + | let server = config::server(&self.app); | |
| 1217 | + | if let Ok(Some((version, bytes))) = self.newer_release(&server) | |
| 1218 | + | && native::put(&folder(&self.app), &bytes, &version).is_ok() | |
| 1219 | + | { | |
| 1220 | + | self.note(None, Some(version)); | |
| 1221 | + | self.not_before = Instant::now(); | |
| 1222 | + | return; | |
| 1223 | + | } | |
| 1224 | + | self.not_before = Instant::now() + MOST_BACKOFF; | |
| 1225 | + | return self.set(Code::NoLauncher, detail(why)); | |
| 1226 | + | } | |
| 1227 | + | self.not_before = Instant::now() + LOOK_AGAIN; | |
| 1228 | + | self.set(Code::SandboxFailed, Extra { detail: Some(why), wait_seconds: Some(LOOK_AGAIN.as_secs()), ..Extra::default() }); | |
| 1229 | + | } | |
| 1230 | + | ||
| 1231 | + | /// Runs a launcher command for the sandbox's run, reading its output. | |
| 1232 | + | fn spawn_step(&mut self, step: Step, args: Vec<std::ffi::OsString>) -> Result<(), String> { | |
| 1233 | + | let binary = native::binary(&folder(&self.app)); | |
| 1234 | + | let mut child = launcher::command(&binary, args).spawn().map_err(|e| format!("g1t-runner sandbox {} could not start: {e}", step.name()))?; | |
| 1235 | + | let _ = std::fs::write(folder(&self.app).join("pid"), child.id().to_string()); | |
| 1236 | + | let Some(generation) = self.run.as_ref().map(|run| run.generation) else { | |
| 1237 | + | native::stop(&mut child); | |
| 1238 | + | return Ok(()); | |
| 1239 | + | }; | |
| 1240 | + | self.follow(generation, &mut child); | |
| 1241 | + | let run = self.run.as_mut().expect("checked above"); | |
| 1242 | + | run.json = None; | |
| 1243 | + | run.process = Process::Sandbox(Some((step, child))); | |
| 1244 | + | Ok(()) | |
| 1245 | + | } | |
| 1246 | + | ||
| 1247 | + | /// `update` on the running sandbox, while it has no job. | |
| 1248 | + | fn sandbox_step(&mut self, step: Step) { | |
| 1249 | + | if self.run.as_ref().is_none_or(|run| run.step().is_some()) { | |
| 1250 | + | return; | |
| 1251 | + | } | |
| 1252 | + | let dir = native_dir(&self.app, Backend::Sandboxed); | |
| 1253 | + | if let Err(why) = self.spawn_step(step, launcher::update_args(&dir)) { | |
| 1254 | + | self.write_log(&format!("g1t: {why}")); | |
| 1255 | + | } | |
| 1256 | + | } | |
| 1257 | + | ||
| 1258 | + | /// Asks the sandbox how it is, every few seconds, on a thread of its own. | |
| 1259 | + | fn poll(&mut self) { | |
| 1260 | + | let Some(run) = self.run.as_mut().filter(|run| run.engine == Engine::Launcher) else { return }; | |
| 1261 | + | if run.polling || run.polled_at.is_some_and(|at| at.elapsed() < launcher::STATUS_EVERY) { | |
| 1262 | + | return; | |
| 1263 | + | } | |
| 1264 | + | run.polling = true; | |
| 1265 | + | run.polled_at = Some(Instant::now()); | |
| 1266 | + | let (tx, generation) = (self.tx.clone(), run.generation); | |
| 1267 | + | let binary = native::binary(&folder(&self.app)); | |
| 1268 | + | let dir = native_dir(&self.app, Backend::Sandboxed); | |
| 1269 | + | let spawned = std::thread::Builder::new().name("g1t-sandbox-status".into()).spawn(move || { | |
| 1270 | + | let _ = tx.send(Ask::Sandbox(generation, launcher::status(&binary, &dir).map(Box::new))); | |
| 1271 | + | }); | |
| 1272 | + | if spawned.is_err() { | |
| 1273 | + | run.polling = false; | |
| 1274 | + | } | |
| 1275 | + | } | |
| 1276 | + | ||
| 1277 | + | /// What a poll said. | |
| 1278 | + | fn polled(&mut self, generation: u64, status: Result<Box<Status>, String>) { | |
| 1279 | + | let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return }; | |
| 1280 | + | run.polling = false; | |
| 1281 | + | if std::mem::take(&mut run.stale) { | |
| 1282 | + | return; | |
| 1283 | + | } | |
| 1284 | + | match status { | |
| 1285 | + | Ok(status) => self.sandbox_now(*status), | |
| 1286 | + | // Once is nothing: the next poll asks again. | |
| 1287 | + | Err(why) => self.write_log(&format!("g1t: the sandbox's status: {why}")), | |
| 1288 | + | } | |
| 1289 | + | } | |
| 1290 | + | ||
| 1291 | + | /// Keeps what a status says for the tray, settings and the site. | |
| 1292 | + | fn seen(&mut self, status: &Status) { | |
| 1293 | + | let shared = self.app.state::<Shared>(); | |
| 1294 | + | let mut now = shared.now.lock().unwrap(); | |
| 1295 | + | now.sandbox = Some(status.clone()); | |
| 1296 | + | if let Some(version) = status.version() { | |
| 1297 | + | now.version = Some(version); | |
| 1298 | + | } | |
| 1299 | + | now.job = status.job().map(launcher::Job::said); | |
| 1300 | + | } | |
| 1301 | + | ||
| 1302 | + | /// The sandbox as it is now, while it is the run. | |
| 1303 | + | fn sandbox_now(&mut self, status: Status) { | |
| 1304 | + | let had = self.app.state::<Shared>().now.lock().unwrap().job.clone(); | |
| 1305 | + | let Some(run) = self.run.as_mut() else { return }; | |
| 1306 | + | let step = run.step(); | |
| 1307 | + | let job = status.job().map(launcher::Job::said); | |
| 1308 | + | let was = had.filter(|_| run.busy); | |
| 1309 | + | run.busy = job.is_some(); | |
| 1310 | + | run.listening = status.running(); | |
| 1311 | + | let update_failed = self.update_failed.clone(); | |
| 1312 | + | if was != job { | |
| 1313 | + | if let Some(ended) = &was { | |
| 1314 | + | self.write_log(&format!("g1t: {ended} ended.")); | |
| 1315 | + | } | |
| 1316 | + | if let Some(took) = &job { | |
| 1317 | + | self.write_log(&format!("g1t: Took {took}.")); | |
| 950 | 1318 | } | |
| 951 | − | Engine::Launcher | Engine::Native { .. } => { | |
| 952 | − | let server = config::server(&self.app); | |
| 953 | − | let folder = folder(&self.app); | |
| 954 | − | let Ok(manifest) = native::latest(&server) else { return }; | |
| 955 | − | if !native::installed(&folder).is_none_or(|have| native::newer(&manifest.version, &have)) { | |
| 956 | − | return; | |
| 1319 | + | } | |
| 1320 | + | self.seen(&status); | |
| 1321 | + | match step { | |
| 1322 | + | Some(Step::Up) => return self.set(Code::Starting, detail(STARTING_SANDBOX)), | |
| 1323 | + | Some(Step::Update) if status.state == State::Updating => return self.set(Code::Updating, detail("Updating the sandbox to the newest image.")), | |
| 1324 | + | _ => {} | |
| 1325 | + | } | |
| 1326 | + | let (code, extra) = shown(&status); | |
| 1327 | + | if step.is_none() && !status.running() && !matches!(status.state, State::Starting | State::Updating) { | |
| 1328 | + | return self.sandbox_stopped(&status, code, extra); | |
| 1329 | + | } | |
| 1330 | + | match (code, update_failed) { | |
| 1331 | + | (Code::Idle, Some(why)) => self.set(Code::UpdateFailed, detail(why)), | |
| 1332 | + | (code, _) => self.set(code, extra), | |
| 1333 | + | } | |
| 1334 | + | } | |
| 1335 | + | ||
| 1336 | + | /// The sandbox stopped by itself, or never got going: started again, | |
| 1337 | + | /// waiting longer each time, or registered again when that is why. | |
| 1338 | + | fn sandbox_stopped(&mut self, status: &Status, code: Code, extra: Extra) { | |
| 1339 | + | let error = status.error.clone().or_else(|| extra.detail.clone()).unwrap_or_default(); | |
| 1340 | + | if self.registration_trouble(&error) { | |
| 1341 | + | return; | |
| 1342 | + | } | |
| 1343 | + | self.run = None; | |
| 1344 | + | self.generation += 1; | |
| 1345 | + | self.failures += 1; | |
| 1346 | + | let wait = Duration::from_secs(15 * 2u64.pow(self.failures.min(6))).min(MOST_BACKOFF); | |
| 1347 | + | self.not_before = Instant::now() + wait; | |
| 1348 | + | let (code, extra) = match code { | |
| 1349 | + | Code::Idle | Code::Busy | Code::Starting | Code::Updating => (Code::SandboxFailed, Extra { detail: status.error.clone().or_else(|| some_text("The sandbox stopped by itself.")), ..Extra::default() }), | |
| 1350 | + | code => (code, extra), | |
| 1351 | + | }; | |
| 1352 | + | self.set(code, Extra { wait_seconds: Some(wait.as_secs()), ..extra }); | |
| 1353 | + | } | |
| 1354 | + | ||
| 1355 | + | /// When the sandbox's trouble is its registration: true when the app | |
| 1356 | + | /// has seen to it, and the next start registers again. | |
| 1357 | + | fn registration_trouble(&mut self, error: &str) -> bool { | |
| 1358 | + | match launcher::why(error) { | |
| 1359 | + | launcher::Why::TokenRefused | launcher::Why::Unregistered => { | |
| 1360 | + | if let Some(mut kept) = SandboxRegistration::load(&self.app) { | |
| 1361 | + | kept.registered = false; | |
| 1362 | + | kept.save(&self.app); | |
| 957 | 1363 | } | |
| 958 | − | let bytes = match native::download(&server, &manifest) { | |
| 959 | − | Ok(bytes) => bytes, | |
| 960 | − | Err(why) => { | |
| 961 | − | let why = format!("g1t-runner {} couldn't be installed: {why}", manifest.version); | |
| 962 | − | self.write_log(&format!("g1t: {why}")); | |
| 963 | − | return self.set(Code::UpdateFailed, detail(why)); | |
| 964 | − | } | |
| 965 | − | }; | |
| 966 | − | self.set(Code::Updating, detail(format!("Updating g1t-runner to {}.", manifest.version))); | |
| 967 | 1364 | self.halt(); | |
| 968 | − | match native::put(&folder, &bytes, &manifest.version) { | |
| 969 | − | Ok(()) if engine == Engine::Launcher => { | |
| 970 | − | let backend = config::settings(&self.app).runner_backend; | |
| 971 | − | launcher::update(&native::binary(&folder), &native_dir(&self.app, backend)); | |
| 972 | − | } | |
| 973 | − | Ok(()) => {} | |
| 974 | − | Err(why) => self.write_log(&format!("g1t: g1t-runner {} couldn't be put in place: {why}", manifest.version)), | |
| 975 | − | } | |
| 976 | 1365 | self.not_before = Instant::now(); | |
| 1366 | + | true | |
| 1367 | + | } | |
| 1368 | + | launcher::Why::Forgotten => { | |
| 1369 | + | self.halt(); | |
| 1370 | + | self.forget_sandbox(); | |
| 1371 | + | self.not_before = Instant::now(); | |
| 1372 | + | true | |
| 1373 | + | } | |
| 1374 | + | _ => false, | |
| 1375 | + | } | |
| 1376 | + | } | |
| 1377 | + | ||
| 1378 | + | /// A launcher command ended: what it printed last is the sandbox's status. | |
| 1379 | + | fn launched(&mut self, generation: u64) { | |
| 1380 | + | let Some(run) = self.run.as_mut().filter(|run| run.generation == generation) else { return }; | |
| 1381 | + | let Process::Sandbox(slot) = &mut run.process else { return }; | |
| 1382 | + | let Some((step, mut child)) = slot.take() else { return }; | |
| 1383 | + | let worked = child.wait().is_ok_and(|status| status.success()); | |
| 1384 | + | let _ = std::fs::remove_file(folder(&self.app).join("pid")); | |
| 1385 | + | let printed = run.json.take().unwrap_or_default().join("\n"); | |
| 1386 | + | run.stale = run.polling; | |
| 1387 | + | let with_token = run.with_token; | |
| 1388 | + | let status = launcher::parse(&printed); | |
| 1389 | + | let error = match (&status, worked) { | |
| 1390 | + | (Ok(status), true) => status.error.clone().filter(|_| !status.running()), | |
| 1391 | + | (Ok(status), false) => Some(status.error.clone().unwrap_or_else(|| format!("g1t-runner sandbox {} failed.", step.name()))), | |
| 1392 | + | (Err(why), _) => Some(why.clone()), | |
| 1393 | + | }; | |
| 1394 | + | let status = status.ok(); | |
| 1395 | + | let running = status.as_ref().is_some_and(Status::running); | |
| 1396 | + | if let Some(error) = &error { | |
| 1397 | + | self.write_log(&format!("g1t: sandbox {}: {error}", step.name())); | |
| 1398 | + | } | |
| 1399 | + | // Registered: from now on it starts with no token. | |
| 1400 | + | if step == Step::Up && with_token && (error.is_none() || running) { | |
| 1401 | + | let app = self.app.clone(); | |
| 1402 | + | if let Some(run) = self.run.as_mut() { | |
| 1403 | + | run.with_token = false; | |
| 1404 | + | let registration = Registration { server: config::server(&app).to_string(), workspace: run.workspace.clone(), backend: Backend::Sandboxed }; | |
| 1405 | + | SandboxRegistration { server: registration.server.clone(), workspace: registration.workspace.clone(), registered: true }.save(&app); | |
| 1406 | + | registration.save(&app); | |
| 1407 | + | } | |
| 1408 | + | } | |
| 1409 | + | if let Some(error) = error.clone() { | |
| 1410 | + | if self.registration_trouble(&error) { | |
| 1411 | + | return; | |
| 1412 | + | } | |
| 1413 | + | if step == Step::Update { | |
| 1414 | + | self.update_failed = Some(format!("The sandbox's image couldn't be updated: {error}")); | |
| 1415 | + | } else if !running { | |
| 1416 | + | // Nothing runs: tidied away, and tried again in a while. | |
| 1417 | + | self.halt(); | |
| 1418 | + | self.failures += 1; | |
| 1419 | + | let wait = Duration::from_secs(15 * 2u64.pow(self.failures.min(6))).min(MOST_BACKOFF); | |
| 1420 | + | self.not_before = Instant::now() + wait; | |
| 1421 | + | let (code, cause, fix) = status | |
| 1422 | + | .as_ref() | |
| 1423 | + | .and_then(diagnosis::sandbox_trouble) | |
| 1424 | + | .unwrap_or_else(|| (diagnosis::sandbox_code(launcher::why(&error)), error.clone(), None)); | |
| 1425 | + | return self.set(code, Extra { detail: Some(cause), fix, wait_seconds: Some(wait.as_secs()), ..Extra::default() }); | |
| 977 | 1426 | } | |
| 1427 | + | } else if step == Step::Up { | |
| 1428 | + | self.failures = 0; | |
| 1429 | + | } else { | |
| 1430 | + | self.update_failed = None; | |
| 978 | 1431 | } | |
| 1432 | + | if step == Step::Update && !running { | |
| 1433 | + | // Updated while stopped: it starts again with the new image. | |
| 1434 | + | self.run = None; | |
| 1435 | + | self.generation += 1; | |
| 1436 | + | self.not_before = Instant::now(); | |
| 1437 | + | return; | |
| 1438 | + | } | |
| 1439 | + | if let Some(status) = status { | |
| 1440 | + | self.sandbox_now(status); | |
| 1441 | + | } | |
| 979 | 1442 | } | |
| 980 | 1443 | } | |
| 981 | 1444 | ||
| 1445 | + | /// What the tray says while the sandbox starts. | |
| 1446 | + | const STARTING_SANDBOX: &str = "Starting the sandbox. The first time, it downloads and checks the sandbox's image, which takes a few minutes."; | |
| 1447 | + | ||
| 1448 | + | fn some_text(text: &str) -> Option<String> { | |
| 1449 | + | Some(text.to_owned()) | |
| 1450 | + | } | |
| 1451 | + | ||
| 1452 | + | /// What the app shows for how the sandbox is, with no launcher command | |
| 1453 | + | /// running: what is wrong with Linux jobs first, else what its runners do. | |
| 1454 | + | fn shown(status: &Status) -> (Code, Extra) { | |
| 1455 | + | if let Some((code, cause, fix)) = diagnosis::sandbox_trouble(status) { | |
| 1456 | + | return (code, Extra { detail: Some(cause), fix, ..Extra::default() }); | |
| 1457 | + | } | |
| 1458 | + | let linux = status.linux(); | |
| 1459 | + | match (linux.state, status.job()) { | |
| 1460 | + | (_, Some(_)) => (Code::Busy, Extra::default()), | |
| 1461 | + | (State::Running, None) => (Code::Idle, Extra::default()), | |
| 1462 | + | (State::Starting, None) => (Code::Starting, detail(STARTING_SANDBOX)), | |
| 1463 | + | (State::Updating, None) => (Code::Updating, detail("Updating the sandbox to the newest image.")), | |
| 1464 | + | // Linux jobs aren't running, but Windows jobs are. | |
| 1465 | + | _ if status.running() => (Code::Idle, Extra::default()), | |
| 1466 | + | _ => (Code::SandboxFailed, detail("The sandbox isn't running.")), | |
| 1467 | + | } | |
| 1468 | + | } | |
| 1469 | + | ||
| 982 | 1470 | /// What `register-and-run` reads: where g1t is, the runner's name and | |
| 983 | 1471 | /// labels, and the token, the first time only. The app updates the runner, | |
| 984 | 1472 | /// so the runner does not update itself; registering again takes the | |
| ⋯ | |||
| 1045 | 1533 | assert!(!again.iter().any(|(name, _)| *name == "G1T_RUNNER_TOKEN" || *name == "G1T_RUNNER_NO_DOCKER")); | |
| 1046 | 1534 | } | |
| 1047 | 1535 | ||
| 1536 | + | fn status(json: &str) -> Status { | |
| 1537 | + | launcher::parse(json).unwrap() | |
| 1538 | + | } | |
| 1539 | + | ||
| 1540 | + | #[test] | |
| 1541 | + | fn the_sandbox_shows_what_its_runners_do_and_linux_trouble_first() { | |
| 1542 | + | let running = status(r#"{"schema":1,"state":"running","capabilities":[{"id":"linux","available":true,"state":"running"},{"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not)."}]}"#); | |
| 1543 | + | assert_eq!(shown(&running).0, Code::Idle); | |
| 1544 | + | let busy = status(r#"{"schema":1,"state":"running","job":{"kind":"workflow","id":"j","name":"build","repo":"acme/web"}}"#); | |
| 1545 | + | assert_eq!(shown(&busy).0, Code::Busy); | |
| 1546 | + | let starting = status(r#"{"schema":1,"state":"starting","capabilities":[{"id":"linux","available":true,"state":"starting"}]}"#); | |
| 1547 | + | assert_eq!(shown(&starting).0, Code::Starting); | |
| 1548 | + | let updating = status(r#"{"schema":1,"state":"updating","capabilities":[{"id":"linux","available":true,"state":"updating"}]}"#); | |
| 1549 | + | assert_eq!(shown(&updating).0, Code::Updating); | |
| 1550 | + | // Windows jobs run while WSL is missing: what is wrong with Linux jobs is what it says. | |
| 1551 | + | let no_wsl = status(r#"{"schema":1,"state":"error","error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.","capabilities":[ | |
| 1552 | + | {"id":"linux","available":false,"state":"error","cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.","fix":"Open a terminal as administrator and run `wsl --install --no-distribution`."}, | |
| 1553 | + | {"id":"windows","available":true,"state":"running"}]}"#); | |
| 1554 | + | let (code, extra) = shown(&no_wsl); | |
| 1555 | + | assert_eq!(code, Code::NoWsl); | |
| 1556 | + | assert!(extra.detail.unwrap().starts_with("WSL is not installed")); | |
| 1557 | + | let failed = status(r#"{"schema":1,"state":"error","error":"the sandbox did not start within 180 seconds","capabilities":[{"id":"linux","available":true,"state":"error"}]}"#); | |
| 1558 | + | let (code, extra) = shown(&failed); | |
| 1559 | + | assert_eq!((code, extra.detail.as_deref()), (Code::SandboxFailed, Some("the sandbox did not start within 180 seconds"))); | |
| 1560 | + | let stopped = status(r#"{"schema":1,"state":"stopped","capabilities":[{"id":"linux","available":true,"state":"stopped"},{"id":"windows","available":true,"state":"stopped"}]}"#); | |
| 1561 | + | assert_eq!(shown(&stopped).0, Code::SandboxFailed); | |
| 1562 | + | } | |
| 1563 | + | ||
| 1048 | 1564 | #[test] | |
| 1049 | 1565 | fn the_site_names_workspaces_in_its_own_words() { | |
| 1050 | 1566 | let offered: Vec<Offered> = serde_json::from_str(r#"[{"slug":"acme","name":"Acme"},{"slug":"solo"}]"#).unwrap(); | |
| 163 | 163 | runner has `self-hosted`, its OS and architecture (`linux` for a runner | |
| 164 | 164 | whose jobs run in containers, whatever the computer is), and `desktop`. | |
| 165 | 165 | It is named after your computer, and a new registration from the same | |
| 166 | − | computer takes the place of the old one. | |
| 166 | + | computer takes the place of the old one. Sandboxed, the computer has a | |
| 167 | + | runner for each kind of job it can run, named after it with `-linux` or | |
| 168 | + | `-windows`: `build-box-linux` has `linux`, and `build-box-windows` has | |
| 169 | + | `windows`, on a Windows computer with Windows Sandbox. | |
| 167 | 170 | ||
| 168 | 171 | ### How jobs run | |
| 169 | 172 | ||
| ⋯ | |||
| 173 | 176 | ||
| 174 | 177 | | Choice | How | [Isolation](/guides/self-hosted-runners/#isolation) | What a job can reach | | |
| 175 | 178 | | --- | --- | --- | --- | | |
| 176 | − | | **Sandboxed** (the default) | The runner in a sandbox of its own, and each job in a container inside that. On Linux, the runner's image under [Sysbox](https://github.com/nestybox/sysbox), through your Docker, without its socket. On Windows and macOS, a small virtual machine the app makes, once that is released; until then it says *a sandbox isn't available on this computer yet*. | `nested-container` | Its own container. Not your computer. | | |
| 179 | + | | **Sandboxed** (the default) | [The sandbox](/guides/self-hosted-runners/#sandbox-on-your-computer), which the app sets up and starts itself. Linux jobs run in a small Linux VM of the runner's own, each in a container inside it: a WSL 2 distribution on Windows, a VM under Apple's Virtualization framework on macOS, and on Linux the runner's image under [Sysbox](https://github.com/nestybox/sysbox), or a VM under QEMU with KVM. On Windows 11 Pro, Enterprise and Education, Windows jobs run too, each in a Windows Sandbox of its own. | `nested-container`; `vm` for Windows jobs | Its own container, or its own Windows Sandbox. Not your computer. | | |
| 177 | 180 | | **In Docker** | `g1t-runner` on your computer, driving your Docker. Each job is a container of its own. | `container` | Its own container; but the runner controls your Docker, which can reach your files (Docker Desktop shares your home folder with its VM), so this is not a sandbox. | | |
| 178 | 181 | | **Directly on this computer** | `g1t-runner` on your computer, each job a process in a folder of its own. For jobs that need the system itself, such as building a Windows installer. | `host` | Everything your account can: your files, your keys, what an earlier job left. Not a sandbox. Never given a public repository's work. | | |
| 179 | 182 | ||
| 180 | − | On Linux, the sandboxed runner is this container, which the app starts, | |
| 181 | − | replaces and stops itself: | |
| 183 | + | What the sandbox needs on each computer: | |
| 184 | + | ||
| 185 | + | | Computer | Linux jobs | Windows jobs | | |
| 186 | + | | --- | --- | --- | | |
| 187 | + | | Windows 11, any edition | WSL, installed once: `wsl --install --no-distribution` in an admin terminal, then a restart. The app's **Copy command** button copies it. | Windows Sandbox, on Pro, Enterprise and Education, version 24H2 or later, turned on in Windows features. Home has none, and Linux jobs still run. | | |
| 188 | + | | macOS 13 or later | [vfkit](https://github.com/crc-org/vfkit): `brew install vfkit` | Not released yet | | |
| 189 | + | | Linux | Sysbox 0.7 or later in Docker, or QEMU with KVM | None: Linux jobs are native | | |
| 182 | 190 | ||
| 183 | − | ```sh | |
| 184 | − | docker run -d --name g1t-desktop-runner --runtime=sysbox-runc \ | |
| 185 | − | -v g1t-desktop-runner:/data \ | |
| 186 | − | -e G1T_RUNNER_URL -e G1T_RUNNER_NAME -e G1T_RUNNER_LABELS=desktop … \ | |
| 187 | − | g1t.sh/flagon-io/g1t-runner:latest register-and-run | |
| 188 | − | ``` | |
| 191 | + | The sandbox also needs virtualization turned on in your computer's | |
| 192 | + | firmware (Intel VT-x or AMD-V), which most computers have on. | |
| 189 | 193 | ||
| 190 | − | Its registration is kept on the `g1t-desktop-runner` volume. For the | |
| 191 | − | other two choices the app keeps `g1t-runner` in its data folder | |
| 194 | + | For the sandbox, the app runs the same commands you would, with its own | |
| 195 | + | copy of `g1t-runner` and its state in the app's data folder | |
| 192 | 196 | (`%APPDATA%\sh.g1t.desktop\runner` on Windows, | |
| 193 | 197 | `~/Library/Application Support/sh.g1t.desktop/runner` on macOS, | |
| 194 | − | `~/.local/share/sh.g1t.desktop/runner` on Linux), with each choice's | |
| 195 | − | registration beside it. | |
| 198 | + | `~/.local/share/sh.g1t.desktop/runner` on Linux): | |
| 199 | + | ||
| 200 | + | | When | The app runs | | |
| 201 | + | | --- | --- | | |
| 202 | + | | A few seconds after g1t opens | `g1t-runner sandbox up --json --state-dir <data folder>/sandbox --url <server> --name <computer> --labels desktop`, with `--token` the first time only. The first `up` downloads and checks the sandbox's image and makes the VM, which takes a few minutes. | | |
| 203 | + | | Every few seconds while it runs | `g1t-runner sandbox status --json`, for the tray, the settings and the Runners page. | | |
| 204 | + | | When g1t quits, or you press **Stop** | `g1t-runner sandbox down`: a job that is running is cut short. | | |
| 205 | + | | When you choose another way of running jobs | `g1t-runner sandbox down --drain`, once the job that is running ends. The sandbox keeps its registration, so choosing Sandboxed again needs no new one. | | |
| 206 | + | | With a new release, while it has no job | `g1t-runner sandbox update`: the newest signed image, the same registration. | | |
| 207 | + | ||
| 208 | + | The other two choices run `g1t-runner` from the same data folder, with | |
| 209 | + | each one's registration beside it. | |
| 210 | + | ||
| 211 | + | On Windows the sandbox is the WSL distribution `g1t-sandbox`, of which a | |
| 212 | + | computer has one. If you made one yourself with `g1t-runner sandbox up`, | |
| 213 | + | the app leaves it alone and says *this computer has another sandbox*: | |
| 214 | + | remove it with `g1t-runner sandbox down --delete` for the app to make its | |
| 215 | + | own, or keep it and turn the app's runner off. | |
| 196 | 216 | ||
| 197 | 217 | ### Registering | |
| 198 | 218 | ||
| ⋯ | |||
| 207 | 227 | The workspace is the one you were in when it first registered; choose | |
| 208 | 228 | another under **Workspace** in the settings. Changing it, or how jobs | |
| 209 | 229 | run, waits for a job that is running to end, then removes the old | |
| 210 | − | registration and makes a new one. | |
| 230 | + | registration and makes a new one. The sandbox is registered with one | |
| 231 | + | workspace, so moving it to another removes it and makes it again, image | |
| 232 | + | and all; its old runners stay listed on g1t, offline, until you remove | |
| 233 | + | them there. | |
| 211 | 234 | ||
| 212 | 235 | ### Keeping it up to date | |
| 213 | 236 | ||
| 214 | 237 | The app keeps the runner up to date: soon after it starts and every six | |
| 215 | 238 | hours, while the runner has no job, it looks for a newer `g1t-runner` in | |
| 216 | − | `g1t.sh/downloads/runner/latest.json` (or a newer image, for the | |
| 217 | − | sandbox), downloads it, checks it against the release's SHA-256, and puts | |
| 218 | − | it in place. A job is never cut short for an update, and one that fails | |
| 219 | − | leaves the runner as it was. Turning off **Download updates by | |
| 220 | − | themselves** turns these off too. | |
| 239 | + | `g1t.sh/downloads/runner/latest.json`, downloads it, checks it against | |
| 240 | + | the release's SHA-256, and puts it in place; the sandbox then takes the | |
| 241 | + | release's image, whose signature and SHA-256 it checks itself. A job is | |
| 242 | + | never cut short for an update, and one that fails leaves the runner as it | |
| 243 | + | was. Turning off **Download updates by themselves** turns these off too. | |
| 221 | 244 | ||
| 222 | 245 | ### Seeing it | |
| 223 | 246 | ||
| 224 | 247 | | Where | What it shows | | |
| 225 | 248 | | --- | --- | | |
| 226 | 249 | | The tray | A row with what the runner is doing, *Runner: Waiting for jobs*, which opens the settings; **Stop running jobs** or **Start running jobs** under it. The icon's hover text says it too. | | |
| 227 | − | | The settings window | What it is doing; when it isn't running, why and how to fix it, with the fix's buttons; how jobs run; the workspace; your computer's name and the runner's version; **View on g1t**; and the runner's recent log, with **Open the whole log**. | | |
| 228 | − | | The workspace's [Runners page](/guides/self-hosted-runners/#the-runners-page) | **Desktops**: every computer with the app in the workspace, with what each is doing, or why it isn't running and the fix, in the same words. Inside the app, this computer comes first, live, with its buttons. A computer whose g1t is closed says *g1t is closed on* its name. | | |
| 250 | + | | The settings window | What it is doing; when it isn't running, why and how to fix it, with the fix's buttons; sandboxed, a line for each kind of job, *Linux jobs: Waiting for jobs*, *Windows jobs: Not available* and why; how jobs run; the workspace; your computer's name and the runner's version; **View on g1t**; and the runner's recent log, with **Open the whole log**. | | |
| 251 | + | | The workspace's [Runners page](/guides/self-hosted-runners/#the-runners-page) | **Desktops**: every computer with the app in the workspace, with what each is doing, or why it isn't running and the fix, in the same words, and each kind of job a sandboxed one runs. Inside the app, this computer comes first, live, with its buttons. A computer whose g1t is closed says *g1t is closed on* its name. | | |
| 229 | 252 | ||
| 230 | 253 | ### When it isn't running | |
| 231 | 254 | ||
| ⋯ | |||
| 243 | 266 | | *Not running: no workspace to run jobs for* | You don't own a workspace, and only owners add runners. | Ask an owner, or **Create a workspace**. | | |
| 244 | 267 | | *Couldn't register with* a workspace: *only its owners can* | You aren't an owner of it. | Ask an owner to make you one, or **Choose workspace**. | | |
| 245 | 268 | | *Couldn't register with* a workspace, with g1t's reason | g1t refused the registration, such as for too many tokens in an hour. | **Retry**; it also tries again by itself. | | |
| 246 | − | | *Not running: Docker isn't running* | Running jobs in Docker, or the sandbox on Linux, needs Docker, and it isn't running or isn't installed. | **Open Docker** (Docker Desktop), or **Get Docker**. It starts by itself once Docker answers. | | |
| 247 | − | | *Not running: a sandbox isn't available on this computer yet* | The sandbox for Windows and macOS isn't released. | Wait for it, or **Choose how jobs run**: in Docker or directly on this computer, neither of which is a sandbox. | | |
| 248 | − | | *Not running: Sysbox isn't installed* | The sandbox on Linux needs Sysbox in your Docker. | **Install Sysbox**. | | |
| 269 | + | | *Not running: Docker isn't running* | Running jobs in Docker needs Docker, and it isn't running or isn't installed. | **Open Docker** (Docker Desktop), or **Get Docker**. It starts by itself once Docker answers. | | |
| 270 | + | | *Not running: WSL isn't installed* | Windows: Linux jobs run in a WSL distribution of the runner's own. | Run `wsl --install --no-distribution` in an admin terminal (**Copy command**), then restart Windows. Every edition of Windows 11 has it, Home included. | | |
| 271 | + | | *Not running: WSL uses mirrored networking* | `networkingMode=mirrored` in `%USERPROFILE%\.wslconfig` lets Linux reach your computer's own `localhost`. | Remove it, run `wsl --shutdown`, then **Retry**. | | |
| 272 | + | | *Not running: virtualization is off* | The sandbox is a small VM, and virtualization is off in your computer's firmware, or Virtual Machine Platform is off in Windows features. | Turn them on (**Open Windows features** on Windows), then restart. | | |
| 273 | + | | *Not running: vfkit isn't installed* | macOS: the sandbox's VM runs under vfkit. | Run `brew install vfkit` (**Copy command**). It starts by itself once vfkit is there. | | |
| 274 | + | | *Not running: no Sysbox or KVM* | Linux: the sandbox needs Sysbox in Docker, or QEMU with KVM. | **Install Sysbox**, or turn on virtualization and install QEMU. | | |
| 275 | + | | *Not running: the sandbox's image didn't download* | The image couldn't be fetched, or its signature or SHA-256 wasn't the release's, with the reason. | It tries again by itself; check the connection, or **Report** it. | | |
| 276 | + | | *Not running: the sandbox can't run here* | Something else the sandbox needs, in its own words. | What it says. | | |
| 277 | + | | *Not running: the sandbox failed* | The sandbox stopped, or didn't start, with its reason. | It starts again by itself, waiting longer each time. **Retry**, or **View log**. | | |
| 278 | + | | *Not running: this computer has another sandbox* | A sandbox you made with `g1t-runner sandbox up`; a computer has one. | Remove it with `g1t-runner sandbox down --delete`, then **Retry**; or keep it, and turn the app's runner off. | | |
| 279 | + | | *Not running: this g1t-runner has no sandbox* | The app's `g1t-runner` is from before the sandbox. | It downloads a newer one by itself; **Retry** looks now. | | |
| 280 | + | | *Windows jobs: Not available* | Windows Sandbox is off, this Windows is Home, or it is older than 24H2. Linux jobs still run. | On Pro, Enterprise or Education, turn on Windows Sandbox (**Open Windows features**) and restart, or update Windows. | | |
| 249 | 281 | | *Offline:* the server *unreachable* | Your computer can't reach g1t. | Check the connection; it carries on by itself. **Retry** tries now. | | |
| 250 | 282 | | *Update failed* | A new version couldn't be downloaded, or its SHA-256 wasn't the release's. | It keeps running the version it has. **Retry**, or **Report** it. | | |
| 251 | 283 | | *Runner stopped (exit code* N*)* | The runner ended by itself, with the reason from its log. | It starts again by itself, waiting longer each time. **Restart** now, or **View log**. | | |
| ⋯ | |||
| 307 | 339 | ||
| 308 | 340 | ## What's not there yet | |
| 309 | 341 | ||
| 310 | − | - **The sandbox, from the app, on Windows and macOS.** The sandbox itself | |
| 311 | − | is built: `g1t-runner sandbox up` makes a computer a self-hosted runner | |
| 312 | − | that nothing a job runs can get out of, with Linux jobs in a Linux VM of | |
| 313 | − | the runner's own and, on Windows 11 Pro and up, Windows jobs each in a | |
| 314 | − | Windows Sandbox ([the sandbox](/guides/self-hosted-runners/#sandbox-on-your-computer)). | |
| 315 | − | The app does not start it for you yet, so until it does, its runner on | |
| 316 | − | Windows and macOS runs jobs only if you choose Docker or this computer | |
| 317 | − | itself; see [how jobs run](#how-jobs-run). | |
| 342 | + | - **Windows jobs on a Mac.** Sandboxed macOS jobs are designed, not | |
| 343 | + | released, so a Mac's sandbox runs Linux jobs only. | |
| 344 | + | - **Choosing the sandbox's share of the computer.** It takes half the | |
| 345 | + | CPUs (at most 8) and a quarter of the memory (2 to 8 GB); the app has no | |
| 346 | + | setting for it yet. | |
| 318 | 347 | - **Agents on your desktop.** The runner takes workflow jobs, and agent | |
| 319 | 348 | work only if the workspace [sends it to its runners](/guides/self-hosted-runners/#agents-on-your-runners). | |
| 320 | 349 | - **Opening the conversation from a notification.** Clicking one brings | |
| 391 | 391 | keeps the runner up to date. It runs jobs sandboxed by default | |
| 392 | 392 | (`nested-container`); running them in Docker (`container`) or directly | |
| 393 | 393 | on the computer (`host`) are settings you choose, and the app never falls | |
| 394 | − | back to them. Its runner has the label `desktop` besides the usual ones. | |
| 394 | + | back to them. Sandboxed, it is [the sandbox](#sandbox-on-your-computer), | |
| 395 | + | which the app sets up with `g1t-runner sandbox up` and shows with | |
| 396 | + | `sandbox status --json`: Linux jobs and, on Windows 11 Pro and up, | |
| 397 | + | Windows jobs, each a runner of its own. Its runners have the label | |
| 398 | + | `desktop` besides the usual ones. | |
| 395 | 399 | ||
| 396 | 400 | ## Add a runner | |
| 397 | 401 |
| 70 | 70 | | Scratchpads | Tools, reports and agents anyone can build on a copy of the data, used in production only after review. | <Status is="coming" /> | | |
| 71 | 71 | | [The desktop app](/guides/desktop/) | g1t in a window of its own for Windows, macOS and Linux, with notifications from your system. | <Status is="preview" /> | | |
| 72 | 72 | | [The phone app](/guides/mobile/) | Chat with your team and its agents, live, with notifications on the phone; review pull requests. Android first, for g1t.sh or your own g1t; not in the stores yet. | <Status is="preview" /> | | |
| 73 | − | | [A runner in the desktop app](/guides/desktop/#the-runner) | Your workspace's self-hosted jobs on your computer while g1t is open, registered and kept up to date by the app, sandboxed by default. The sandbox on Windows and macOS is coming; until then jobs there run in Docker or on the computer, if you choose. | <Status is="preview" /> | | |
| 73 | + | | [A runner in the desktop app](/guides/desktop/#the-runner) | Your workspace's self-hosted jobs on your computer while g1t is open, registered and kept up to date by the app, sandboxed by default: Linux jobs in a sandbox of the runner's own on Windows, macOS and Linux, and Windows jobs in Windows Sandbox on Windows 11 Pro and up. | <Status is="preview" /> | | |
| 74 | 74 | ||
| 75 | 75 | ## Agents | |
| 76 | 76 |
| 3 | 3 | ||
| 4 | 4 | import type { DesktopBackend, DesktopDiagnosis, DesktopRunner } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { BACKEND_LABEL, type DesktopRunnerAction, desktopRunner, useDesktopRunner } from "../lib/desktop-runner"; | |
| 6 | + | import { BACKEND_LABEL, type DesktopRunnerAction, type DesktopRunnerCapability, desktopRunner, useDesktopRunner } from "../lib/desktop-runner"; | |
| 7 | 7 | import { EmptyState, TimeAgo } from "./ui"; | |
| 8 | 8 | import { Badge } from "./ui/badge"; | |
| 9 | 9 | import { Button } from "./ui/button"; | |
| ⋯ | |||
| 26 | 26 | diagnosis: DesktopDiagnosis; | |
| 27 | 27 | /** This computer's fixes, which only work here. */ | |
| 28 | 28 | actions: DesktopRunnerAction[]; | |
| 29 | + | /** Sandboxed: each kind of job, Linux and Windows, with how it is (and here, its fixes). */ | |
| 30 | + | capabilities: DesktopRunnerCapability[]; | |
| 29 | 31 | here: boolean; | |
| 30 | 32 | reportedAt: string | null; | |
| 31 | 33 | }; | |
| ⋯ | |||
| 41 | 43 | version: here.version, | |
| 42 | 44 | diagnosis: here.diagnosis, | |
| 43 | 45 | actions: here.diagnosis.actions, | |
| 46 | + | capabilities: here.diagnosis.capabilities ?? [], | |
| 44 | 47 | here: true, | |
| 45 | 48 | reportedAt: null, | |
| 46 | 49 | }); | |
| ⋯ | |||
| 54 | 57 | version: desktop.version, | |
| 55 | 58 | diagnosis: desktop.open ? desktop.diagnosis : desktop.closed, | |
| 56 | 59 | actions: [], | |
| 60 | + | capabilities: desktop.open ? (desktop.diagnosis.capabilities ?? []).map((c) => ({ ...c, actions: [] })) : [], | |
| 57 | 61 | here: false, | |
| 58 | 62 | reportedAt: desktop.reportedAt, | |
| 59 | 63 | }); | |
| ⋯ | |||
| 61 | 65 | return rows; | |
| 62 | 66 | } | |
| 63 | 67 | ||
| 68 | + | /** A diagnosis's fixes, as buttons: they work only on this computer, in the app. */ | |
| 69 | + | function Actions({ actions }: { actions: DesktopRunnerAction[] }) { | |
| 70 | + | if (actions.length === 0) return null; | |
| 71 | + | return ( | |
| 72 | + | <div className="mt-2 flex flex-wrap gap-2"> | |
| 73 | + | {actions.map((action, at) => ( | |
| 74 | + | <Button key={action.id} size="xs" variant={at === 0 ? "secondary" : "outline"} onClick={() => void desktopRunner()?.act(action.id).catch(() => {})}> | |
| 75 | + | {action.label} | |
| 76 | + | </Button> | |
| 77 | + | ))} | |
| 78 | + | </div> | |
| 79 | + | ); | |
| 80 | + | } | |
| 81 | + | ||
| 64 | 82 | /** | |
| 65 | 83 | * The workspace's desktops: each person's computer with the g1t desktop | |
| 66 | 84 | * app, whose runner runs while g1t is open there. What each says, why it | |
| ⋯ | |||
| 95 | 113 | <p className="mt-1 text-sm text-fg">{row.diagnosis.title}</p> | |
| 96 | 114 | {row.diagnosis.cause && <p className="mt-0.5 text-sm text-muted">{row.diagnosis.cause}</p>} | |
| 97 | 115 | {row.diagnosis.fix && <p className="mt-0.5 text-sm text-fg-soft">{row.diagnosis.fix}</p>} | |
| 98 | − | {row.actions.length > 0 && ( | |
| 99 | − | <div className="mt-2 flex flex-wrap gap-2"> | |
| 100 | − | {row.actions.map((action, at) => ( | |
| 101 | − | <Button key={action.id} size="xs" variant={at === 0 ? "secondary" : "outline"} onClick={() => void desktopRunner()?.act(action.id).catch(() => {})}> | |
| 102 | − | {action.label} | |
| 103 | − | </Button> | |
| 116 | + | <Actions actions={row.actions} /> | |
| 117 | + | {row.capabilities.length > 1 && ( | |
| 118 | + | <ul className="mt-2 space-y-1.5 border-t border-line pt-2"> | |
| 119 | + | {row.capabilities.map((capability) => ( | |
| 120 | + | <li key={capability.id} className="flex items-start gap-2"> | |
| 121 | + | <span className={`mt-1.5 size-1.5 shrink-0 rounded-full ${DOT[capability.tone]}`} aria-hidden /> | |
| 122 | + | <div className="min-w-0"> | |
| 123 | + | <p className="text-sm text-fg"> | |
| 124 | + | {capability.label}: {capability.title} | |
| 125 | + | </p> | |
| 126 | + | {capability.cause && <p className="text-sm text-muted">{capability.cause}</p>} | |
| 127 | + | {capability.fix && <p className="text-sm text-fg-soft">{capability.fix}</p>} | |
| 128 | + | <Actions actions={capability.actions} /> | |
| 129 | + | </div> | |
| 130 | + | </li> | |
| 104 | 131 | ))} | |
| 105 | − | </div> | |
| 132 | + | </ul> | |
| 106 | 133 | )} | |
| 107 | 134 | </div> | |
| 108 | 135 | <div className="shrink-0 text-right text-xs text-faint"> | |
| 46 | 46 | assert.equal(reportDue(last, busy, at + 1000), true); | |
| 47 | 47 | }); | |
| 48 | 48 | ||
| 49 | + | test("a sandboxed desktop reports each kind of job, without its buttons", () => { | |
| 50 | + | const linux = { id: "linux", label: "Linux jobs", tone: "ok" as const, title: "Waiting for jobs", cause: null, fix: null, actions: [] }; | |
| 51 | + | const windows = { | |
| 52 | + | id: "windows", | |
| 53 | + | label: "Windows jobs", | |
| 54 | + | tone: "off" as const, | |
| 55 | + | title: "Not available", | |
| 56 | + | cause: "Windows Sandbox is off, or this edition of Windows has none: Home doesn't.", | |
| 57 | + | fix: "Linux jobs still run.", | |
| 58 | + | actions: [{ id: "windows_features", label: "Open Windows features" }], | |
| 59 | + | }; | |
| 60 | + | const sandboxed = view({ backend: "sandboxed", diagnosis: { code: "idle", tone: "ok", title: "Waiting for jobs", cause: null, fix: null, actions: [], capabilities: [linux, windows] } }); | |
| 61 | + | const report = reportOf(sandboxed)!; | |
| 62 | + | assert.deepEqual(report.diagnosis.capabilities, [ | |
| 63 | + | { id: "linux", label: "Linux jobs", tone: "ok", title: "Waiting for jobs", cause: null, fix: null }, | |
| 64 | + | { id: "windows", label: "Windows jobs", tone: "off", title: "Not available", cause: windows.cause, fix: "Linux jobs still run." }, | |
| 65 | + | ]); | |
| 66 | + | // One kind of job changing is a report due. | |
| 67 | + | const last = { key: reportKey(report), at: 0 }; | |
| 68 | + | const running = reportOf(view({ ...sandboxed, diagnosis: { ...sandboxed.diagnosis, capabilities: [linux, { ...windows, tone: "ok", title: "Waiting for jobs", cause: null, fix: null }] } }))!; | |
| 69 | + | assert.equal(reportDue(last, running, 1000), true); | |
| 70 | + | // What the server keeps: only kinds of job that read, and none for a desktop without any. | |
| 71 | + | const kept = diagnosisOf({ ...report.diagnosis, capabilities: [...report.diagnosis.capabilities!, { id: "x", tone: "loud" }, "linux"] })!; | |
| 72 | + | assert.equal(kept.capabilities?.length, 2); | |
| 73 | + | assert.equal(diagnosisOf({ code: "idle", tone: "ok", title: "Waiting for jobs" })!.capabilities, undefined); | |
| 74 | + | assert.equal(reportOf(view())!.diagnosis.capabilities, undefined); | |
| 75 | + | }); | |
| 76 | + | ||
| 49 | 77 | test("only a diagnosis and a backend the app makes are kept", () => { | |
| 50 | 78 | assert.deepEqual(diagnosisOf({ code: "idle", tone: "ok", title: "Waiting for jobs", cause: "Running sandboxed.", extra: 1 }), { | |
| 51 | 79 | code: "idle", |
| 18 | 18 | */ | |
| 19 | 19 | import { useEffect, useState } from "react"; | |
| 20 | 20 | ||
| 21 | − | import type { DesktopBackend, DesktopDiagnosis, DesktopReport } from "@g1t/contracts"; | |
| 21 | + | import type { DesktopBackend, DesktopCapability, DesktopDiagnosis, DesktopReport } from "@g1t/contracts"; | |
| 22 | 22 | ||
| 23 | 23 | /** A fix, as a button: its id is the app's (`runner_control`). */ | |
| 24 | 24 | export type DesktopRunnerAction = { id: string; label: string }; | |
| 25 | 25 | ||
| 26 | + | /** A kind of job the sandbox runs, with its fixes' buttons (`diagnosis::CapabilityLine` in the app). */ | |
| 27 | + | export type DesktopRunnerCapability = DesktopCapability & { actions: DesktopRunnerAction[] }; | |
| 28 | + | ||
| 26 | 29 | /** What the app says of its runner (`runner::View` in the app). */ | |
| 27 | 30 | export type DesktopRunnerView = { | |
| 28 | − | diagnosis: DesktopDiagnosis & { actions: DesktopRunnerAction[] }; | |
| 31 | + | diagnosis: Omit<DesktopDiagnosis, "capabilities"> & { actions: DesktopRunnerAction[]; capabilities?: DesktopRunnerCapability[] }; | |
| 29 | 32 | closed: DesktopDiagnosis & { actions: DesktopRunnerAction[] }; | |
| 30 | 33 | running: boolean; | |
| 31 | 34 | version: string | null; | |
| ⋯ | |||
| 87 | 90 | /** What goes to the server, without the buttons, which only work on that computer. */ | |
| 88 | 91 | export function reportOf(view: DesktopRunnerView): DesktopReport | null { | |
| 89 | 92 | if (!view.workspace) return null; | |
| 90 | − | const plain = ({ code, tone, title, cause, fix }: DesktopDiagnosis): DesktopDiagnosis => ({ code, tone, title, cause, fix }); | |
| 93 | + | const plain = ({ code, tone, title, cause, fix, capabilities }: DesktopDiagnosis): DesktopDiagnosis => { | |
| 94 | + | const said: DesktopDiagnosis = { code, tone, title, cause, fix }; | |
| 95 | + | if (capabilities?.length) said.capabilities = capabilities.map(({ id, label, tone, title, cause, fix }) => ({ id, label, tone, title, cause: cause ?? null, fix: fix ?? null })); | |
| 96 | + | return said; | |
| 97 | + | }; | |
| 91 | 98 | return { workspace: view.workspace, machine: view.machine, backend: view.backend, version: view.version, diagnosis: plain(view.diagnosis), closed: plain(view.closed) }; | |
| 92 | 99 | } | |
| 93 | 100 | ||
| ⋯ | |||
| 99 | 106 | ||
| 100 | 107 | export function reportKey(report: DesktopReport): string { | |
| 101 | 108 | const { diagnosis } = report; | |
| 102 | − | return [report.workspace, report.machine, report.backend, report.version, diagnosis.code, diagnosis.title, diagnosis.cause, diagnosis.fix].join("\u0000"); | |
| 109 | + | const capabilities = (diagnosis.capabilities ?? []).map((c) => [c.id, c.tone, c.title, c.cause, c.fix].join("\u0001")); | |
| 110 | + | return [report.workspace, report.machine, report.backend, report.version, diagnosis.code, diagnosis.title, diagnosis.cause, diagnosis.fix, ...capabilities].join("\u0000"); | |
| 103 | 111 | } | |
| 104 | 112 | ||
| 105 | 113 | /** A string with something in it, or null. */ | |
| ⋯ | |||
| 120 | 128 | const title = text(given.title); | |
| 121 | 129 | const tone = TONES.find((t) => t === given.tone); | |
| 122 | 130 | if (!code || !title || !tone) return null; | |
| 123 | − | return { code, tone, title, cause: text(given.cause), fix: text(given.fix) }; | |
| 131 | + | const said: DesktopDiagnosis = { code, tone, title, cause: text(given.cause), fix: text(given.fix) }; | |
| 132 | + | const capabilities = Array.isArray(given.capabilities) | |
| 133 | + | ? given.capabilities.map(capabilityOf).filter((c): c is DesktopCapability => c !== null).slice(0, MAX_CAPABILITIES) | |
| 134 | + | : []; | |
| 135 | + | if (capabilities.length > 0) said.capabilities = capabilities; | |
| 136 | + | return said; | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /** The most kinds of job a desktop reports (the server keeps as many). */ | |
| 140 | + | const MAX_CAPABILITIES = 4; | |
| 141 | + | ||
| 142 | + | /** A kind of job as the app sent it, or null when it is not one. */ | |
| 143 | + | function capabilityOf(value: unknown): DesktopCapability | null { | |
| 144 | + | if (!value || typeof value !== "object") return null; | |
| 145 | + | const given = value as Record<string, unknown>; | |
| 146 | + | const id = text(given.id); | |
| 147 | + | const label = text(given.label); | |
| 148 | + | const title = text(given.title); | |
| 149 | + | const tone = TONES.find((t) => t === given.tone); | |
| 150 | + | if (!id || !label || !title || !tone) return null; | |
| 151 | + | return { id, label, tone, title, cause: text(given.cause), fix: text(given.fix) }; | |
| 124 | 152 | } | |
| 125 | 153 | ||
| 126 | 154 | /** The words for how a desktop runs jobs, as its settings say them. */ | |
| 250 | 250 | /// A diagnosis, as the app made it. | |
| 251 | 251 | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 252 | 252 | pub struct DesktopDiagnosis { | |
| 253 | − | /// `idle`, `busy`, `no_docker`, `no_sandbox`, `not_allowed`, … | |
| 253 | + | /// `idle`, `busy`, `no_docker`, `no_wsl`, `sandbox_failed`, `not_allowed`, … | |
| 254 | 254 | pub code: String, | |
| 255 | 255 | /// `ok`, `working`, `off`, `warning` or `problem`. | |
| 256 | 256 | pub tone: String, | |
| ⋯ | |||
| 259 | 259 | pub cause: Option<String>, | |
| 260 | 260 | #[serde(default)] | |
| 261 | 261 | pub fix: Option<String>, | |
| 262 | + | /// A sandboxed desktop's kinds of job, each with how it is: Linux jobs | |
| 263 | + | /// can run while Windows jobs cannot. Empty for the other backends. | |
| 264 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] | |
| 265 | + | pub capabilities: Vec<DesktopCapability>, | |
| 262 | 266 | } | |
| 263 | 267 | ||
| 268 | + | /// One kind of job a sandboxed desktop runs, as its app says it. | |
| 269 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 270 | + | pub struct DesktopCapability { | |
| 271 | + | /// `linux`, `windows` or `macos`. | |
| 272 | + | pub id: String, | |
| 273 | + | /// `Linux jobs`. | |
| 274 | + | pub label: String, | |
| 275 | + | pub tone: String, | |
| 276 | + | /// `Waiting for jobs`, `Not available`. | |
| 277 | + | pub title: String, | |
| 278 | + | #[serde(default)] | |
| 279 | + | pub cause: Option<String>, | |
| 280 | + | #[serde(default)] | |
| 281 | + | pub fix: Option<String>, | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | /// The most kinds of job a desktop reports. | |
| 285 | + | pub const MAX_DESKTOP_CAPABILITIES: usize = 4; | |
| 286 | + | ||
| 264 | 287 | /// One person's computer, as its desktop app last said. | |
| 265 | 288 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 266 | 289 | #[serde(rename_all = "camelCase")] | |
| 152 | 152 | title: string; | |
| 153 | 153 | cause: string | null; | |
| 154 | 154 | fix: string | null; | |
| 155 | + | /** A sandboxed desktop's kinds of job, each with how it is: Linux jobs can run while Windows jobs cannot. */ | |
| 156 | + | capabilities?: DesktopCapability[]; | |
| 157 | + | }; | |
| 158 | + | ||
| 159 | + | /** One kind of job a sandboxed desktop runs (`linux`, `windows`, `macos`), as its app says it. */ | |
| 160 | + | export type DesktopCapability = { | |
| 161 | + | id: string; | |
| 162 | + | /** `Linux jobs`. */ | |
| 163 | + | label: string; | |
| 164 | + | tone: DesktopDiagnosis["tone"]; | |
| 165 | + | /** `Waiting for jobs`, `Not available`. */ | |
| 166 | + | title: string; | |
| 167 | + | cause: string | null; | |
| 168 | + | fix: string | null; | |
| 155 | 169 | }; | |
| 156 | 170 | ||
| 157 | 171 | /** How a desktop runs jobs. */ |
| 15 | 15 | use g1t_contracts::billing::{ComputeKind, RecordSandboxArgs}; | |
| 16 | 16 | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 17 | 17 | use g1t_contracts::runners::{ | |
| 18 | − | self as model, ACTIVITY_LIMIT, CloudJob, DesktopDiagnosis, DesktopRunner, DesktopRunnersArgs, ReportDesktopArgs, HandedOverTask, RunnerActivity, RunnerActivityArgs, StuckJob, StuckJobsArgs, Assignment, CancelTaskArgs, CreateRegistrationTokenArgs, DeleteRunnerGroupArgs, EnqueueTaskArgs, FinishedArgs, | |
| 18 | + | self as model, ACTIVITY_LIMIT, CloudJob, DesktopCapability, DesktopDiagnosis, DesktopRunner, DesktopRunnersArgs, ReportDesktopArgs, HandedOverTask, RunnerActivity, RunnerActivityArgs, StuckJob, StuckJobsArgs, Assignment, CancelTaskArgs, CreateRegistrationTokenArgs, DeleteRunnerGroupArgs, EnqueueTaskArgs, FinishedArgs, | |
| 19 | 19 | ListRunnersArgs, Poll, PollArgs, RegisterArgs, Registered, RegistrationToken, RemoveRunnerArgs, RemoveSelfArgs, RouteArgs, Runner, | |
| 20 | 20 | RunnerAuth, RunnerGroup, RunnerGroupsArgs, RunnerSettings, RunnerSettingsArgs, RunnerWork, RunnersOwner, SetRunnerGroupArgs, | |
| 21 | 21 | SetRunnerSettingsArgs, Wanted, | |
| ⋯ | |||
| 726 | 726 | title: clip(&d.title, 200), | |
| 727 | 727 | cause: d.cause.as_deref().map(|t| clip(t, 1000)), | |
| 728 | 728 | fix: d.fix.as_deref().map(|t| clip(t, 1000)), | |
| 729 | + | capabilities: d | |
| 730 | + | .capabilities | |
| 731 | + | .iter() | |
| 732 | + | .take(model::MAX_DESKTOP_CAPABILITIES) | |
| 733 | + | .map(|c| DesktopCapability { | |
| 734 | + | id: clip(&c.id, 20), | |
| 735 | + | label: clip(&c.label, 60), | |
| 736 | + | tone: clip(&c.tone, 20), | |
| 737 | + | title: clip(&c.title, 200), | |
| 738 | + | cause: c.cause.as_deref().map(|t| clip(t, 1000)), | |
| 739 | + | fix: c.fix.as_deref().map(|t| clip(t, 1000)), | |
| 740 | + | }) | |
| 741 | + | .collect(), | |
| 729 | 742 | }; | |
| 730 | 743 | let at = now_ms(); | |
| 731 | 744 | let known = self | |