Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop. | 1 | //! `g1t-runner sandbox`: self-hosted runners on a person's own computer |
| 2 | //! that nothing a job runs can get out of, to the computer's files, its | |
| 3 | //! processes, its other programs or its Docker. | |
| 4 | //! | |
| 5 | //! ```text | |
| 6 | //! g1t-runner sandbox up [--url https://g1t.sh --token g1trt_…] [--name NAME] | |
| 7 | //! [--labels a,b] [--group GROUP] [--cpus N] [--memory 8g] [--disk 64g] | |
| 8 | //! [--no-native | --native] [--state-dir DIR] [--json] | |
| 9 | //! [--image-file PATH] [--runner-image REF] [--runner-image-file TAR] | |
| 10 | //! g1t-runner sandbox down [--drain] [--delete] [--json] | |
| 11 | //! g1t-runner sandbox status [--json] | |
| 12 | //! g1t-runner sandbox update [--image-file PATH] [--json] | |
| 13 | //! ``` | |
| 14 | //! | |
| 15 | //! It gives a computer up to two capabilities, each a runner of its own: | |
| 16 | //! | |
| 17 | //! - **`linux`: Linux jobs, in a VM of the runner's own.** The launcher | |
| 18 | //! makes and owns one small Linux VM per computer (a WSL 2 distribution | |
| 19 | //! on Windows, wsl.rs; a vfkit VM on macOS and a QEMU one on Linux | |
| 20 | //! without Sysbox, vm.rs; on Linux with Sysbox no VM is needed, | |
| 21 | //! sysbox.rs). Inside it the runner's image runs under Sysbox, and each | |
| 22 | //! job is a fresh container inside that, removed after (guest.rs): | |
| 23 | //! `nested-container` isolation. The VM's image is published with each | |
| 24 | //! runner release, in its signed manifest, and checked before use | |
| 25 | //! (image.rs). Its registration is kept apart from its system, so | |
| 26 | //! `update` drains the runner, swaps the image and starts again without | |
| 27 | //! registering again. | |
| 28 | //! - **`windows` (and `macos`): jobs native to the computer's OS**, each in | |
| 29 | //! a throwaway VM of its own (native.rs): Windows Sandbox on Windows 11 | |
| 30 | //! Pro, Enterprise and Education. The runner for these runs on the | |
| 31 | //! computer itself, with `--native`, and only starts and stops those VMs. | |
| 32 | //! `vm` isolation. On a Mac this is designed, not released. | |
| 33 | //! | |
| 34 | //! A runner registers labels only for what it sandboxes: the VM's runner | |
| 35 | //! as `linux`, the native one as `windows` only when Windows Sandbox is | |
| 36 | //! there. Nothing here advertises a native label for host mode. | |
| 37 | //! | |
| 38 | //! ## `status --json` | |
| 39 | //! | |
| 40 | //! The desktop app runs `g1t-runner sandbox status --json` and shows what | |
| 41 | //! it says. One JSON object on standard output, exit code 0 whenever the | |
| 42 | //! status could be read (a sandbox in `error` included): | |
| 43 | //! | |
| 44 | //! ```json | |
| 45 | //! { | |
| 46 | //! "schema": 1, | |
| 47 | //! "state": "running", | |
| 48 | //! "backend": "wsl2", | |
| 49 | //! "runner_version": "0.3.0", | |
| 50 | //! "launcher_version": "0.3.0", | |
| 51 | //! "image_version": "0.3.0", | |
| 52 | //! "isolation": "nested-container", | |
| 53 | //! "job": { "kind": "workflow", "id": "job_…", "name": "build", "repo": "acme/web" }, | |
| 54 | //! "error": null, | |
| 55 | //! "resources": { "cpus": 4, "memory_mib": 8192, "disk_gib": 64 }, | |
| 56 | //! "capabilities": [ | |
| 57 | //! { "id": "linux", "available": true, "state": "running", "isolation": "nested-container", | |
| 58 | //! "runner_version": "0.3.0", "job": null, "cause": null, "fix": null }, | |
| 59 | //! { "id": "windows", "available": false, "state": "absent", "isolation": "vm", | |
| 60 | //! "runner_version": null, "job": null, | |
| 61 | //! "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).", | |
| 62 | //! "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, …" } | |
| 63 | //! ], | |
| 64 | //! "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox" | |
| 65 | //! } | |
| 66 | //! ``` | |
| 67 | //! | |
| 68 | //! The top-level fields are the Linux VM's. `state` is one of `absent` | |
| 69 | //! (never made, or deleted), `stopped`, `starting`, `running`, `updating` | |
| 70 | //! or `error`; `error` is a sentence for a person, set whenever something | |
| 71 | //! is wrong, and `cause`/`fix` say why a capability is unavailable and | |
| 72 | //! what to do. `job` is what the runner is running now, or null. Fields | |
| 73 | //! are only ever added; `schema` changes if one changes meaning. `up`, | |
| 74 | //! `down` and `update` print the same object with `--json` when they are | |
| 75 | //! done, and exit 1 with `error` set when they fail. | |
| 76 | ||
| 77 | // The supervisor runs inside the Linux VM; elsewhere only its builders | |
| 78 | // (tested everywhere) and its commands' parsing are used. | |
| 79 | #[cfg_attr(not(target_os = "linux"), allow(dead_code))] | |
| 80 | pub mod guest; | |
| 81 | mod image; | |
| 82 | mod sysbox; | |
| 83 | #[cfg_attr(not(unix), allow(dead_code))] | |
| 84 | mod vm; | |
| 85 | mod wsl; | |
| 86 | ||
| 87 | use std::path::{Path, PathBuf}; | |
| 88 | use std::process::{Command, Stdio}; | |
| 89 | use std::time::{Duration, Instant}; | |
| 90 | ||
| 91 | use anyhow::{Context, Result, anyhow, bail}; | |
| 92 | use serde::{Deserialize, Serialize}; | |
| 93 | ||
| 94 | use super::native::{Availability, MacVm, WsbFound}; | |
| 95 | use super::{JobStatus, Options, RunnerStatus, VERSION, config, log}; | |
| 96 | use guest::{GuestStatus, Settings}; | |
| 97 | ||
| 98 | /// What `status --json`'s shape is. | |
| 99 | pub const SCHEMA: u32 = 1; | |
| 100 | /// The site whose releases the sandbox's images come from when none was | |
| 101 | /// given. | |
| 102 | pub const DEFAULT_SITE: &str = "https://g1t.sh"; | |
| 103 | ||
| 104 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 105 | pub enum StopMode { | |
| 106 | /// After the running job, if any. | |
| 107 | Drain, | |
| 108 | /// Now: the running job is cut short, and g1t told so. | |
| 109 | Now, | |
| 110 | } | |
| 111 | ||
| 112 | /// The VM's (or the runner container's) share of the computer. | |
| 113 | #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] | |
| 114 | pub struct Resources { | |
| 115 | pub cpus: u32, | |
| 116 | pub memory_mib: u64, | |
| 117 | pub disk_gib: u64, | |
| 118 | } | |
| 119 | ||
| 120 | /// One kind of sandbox that holds the Linux runner. | |
| 121 | pub trait Backend { | |
| 122 | /// What `status` calls it: `wsl2`, `vfkit`, `qemu` or `sysbox`. | |
| 123 | fn name(&self) -> &'static str; | |
| 124 | /// The release files it is made from. | |
| 125 | fn image_keys(&self) -> Vec<String>; | |
| 126 | fn availability(&self) -> Availability; | |
| 127 | fn exists(&self) -> bool; | |
| 128 | fn running(&self) -> bool; | |
| 129 | fn create(&self, local: &image::Local, resources: &Resources) -> Result<()>; | |
| 130 | fn configure(&self, settings: &Settings) -> Result<()>; | |
| 131 | /// A runner image from a file, for a build that is not released. | |
| 132 | fn load_image(&self, tar: &Path) -> Result<()>; | |
| 133 | fn start(&self) -> Result<()>; | |
| 134 | fn guest_status(&self) -> Option<GuestStatus>; | |
| 135 | fn stop(&self, mode: StopMode) -> Result<()>; | |
| 136 | /// The runner's registration, when the sandbox's system and its state | |
| 137 | /// are one (WSL), to carry across an update. | |
| 138 | fn export_state(&self) -> Result<Option<Vec<u8>>>; | |
| 139 | fn import_state(&self, bytes: &[u8]) -> Result<()>; | |
| 140 | /// Removes the sandbox's system, keeping its state where that is apart. | |
| 141 | fn remove_system(&self) -> Result<()> { | |
| 142 | self.remove() | |
| 143 | } | |
| 144 | /// Removes the sandbox entirely. | |
| 145 | fn remove(&self) -> Result<()>; | |
| 146 | } | |
| 147 | ||
| 148 | /// What the launcher keeps in `<state>/sandbox.json`. | |
| 149 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 150 | pub struct Saved { | |
| 151 | #[serde(default)] | |
| 152 | pub url: Option<String>, | |
| 153 | /// The runners' name before `-linux` / `-windows`. | |
| 154 | #[serde(default)] | |
| 155 | pub name: Option<String>, | |
| 156 | #[serde(default)] | |
| 157 | pub labels: Vec<String>, | |
| 158 | #[serde(default)] | |
| 159 | pub group: Option<String>, | |
| 160 | #[serde(default)] | |
| 161 | pub resources: Resources, | |
| 162 | #[serde(default)] | |
| 163 | pub image_version: Option<String>, | |
| 164 | #[serde(default)] | |
| 165 | pub runner_image: Option<String>, | |
| 166 | /// The native capability was turned off (`--no-native`). | |
| 167 | #[serde(default)] | |
| 168 | pub no_native: bool, | |
| 169 | /// `starting` or `updating`, while a launcher (`phase_pid`) does it. | |
| 170 | #[serde(default)] | |
| 171 | pub phase: Option<String>, | |
| 172 | #[serde(default)] | |
| 173 | pub phase_pid: Option<u32>, | |
| 174 | /// What went wrong last, for `status`. | |
| 175 | #[serde(default)] | |
| 176 | pub error: Option<String>, | |
| 177 | } | |
| 178 | ||
| 179 | impl Saved { | |
| 180 | fn path(dir: &Path) -> PathBuf { | |
| 181 | dir.join("sandbox.json") | |
| 182 | } | |
| 183 | ||
| 184 | pub fn load(dir: &Path) -> Saved { | |
| 185 | std::fs::read(Saved::path(dir)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() | |
| 186 | } | |
| 187 | ||
| 188 | pub fn save(&self, dir: &Path) -> Result<()> { | |
| 189 | std::fs::create_dir_all(dir).with_context(|| format!("could not make {}", dir.display()))?; | |
| 190 | write_private(&Saved::path(dir), &serde_json::to_vec_pretty(self)?) | |
| 191 | } | |
| 192 | ||
| 193 | fn site(&self) -> &str { | |
| 194 | self.url.as_deref().unwrap_or(DEFAULT_SITE) | |
| 195 | } | |
| 196 | } | |
| 197 | ||
| 198 | /// What `status --json` prints (see the module's documentation). | |
| 199 | #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] | |
| 200 | pub struct Status { | |
| 201 | pub schema: u32, | |
| 202 | pub state: String, | |
| 203 | pub backend: Option<String>, | |
| 204 | pub runner_version: Option<String>, | |
| 205 | pub launcher_version: String, | |
| 206 | pub image_version: Option<String>, | |
| 207 | pub isolation: Option<String>, | |
| 208 | pub job: Option<JobStatus>, | |
| 209 | pub error: Option<String>, | |
| 210 | pub resources: Resources, | |
| 211 | pub capabilities: Vec<Capability>, | |
| 212 | pub state_dir: String, | |
| 213 | } | |
| 214 | ||
| 215 | /// One kind of job this computer can run sandboxed. | |
| 216 | #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] | |
| 217 | pub struct Capability { | |
| 218 | /// `linux`, `windows` or `macos`. | |
| 219 | pub id: String, | |
| 220 | pub available: bool, | |
| 221 | pub state: String, | |
| 222 | pub isolation: Option<String>, | |
| 223 | pub runner_version: Option<String>, | |
| 224 | pub job: Option<JobStatus>, | |
| 225 | pub cause: Option<String>, | |
| 226 | pub fix: Option<String>, | |
| 227 | } | |
| 228 | ||
| 229 | pub fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { | |
| 230 | let temp = path.with_extension("new"); | |
| 231 | std::fs::write(&temp, bytes).with_context(|| format!("could not write {}", temp.display()))?; | |
| 232 | #[cfg(unix)] | |
| 233 | { | |
| 234 | use std::os::unix::fs::PermissionsExt; | |
| 235 | std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?; | |
| 236 | } | |
| 237 | std::fs::rename(&temp, path).with_context(|| format!("could not write {}", path.display()))?; | |
| 238 | Ok(()) | |
| 239 | } | |
| 240 | ||
| 241 | // --- The computer ------------------------------------------------------------- | |
| 242 | ||
| 243 | /// Whether a process is still running. | |
| 244 | pub fn process_alive(pid: u32) -> bool { | |
| 245 | #[cfg(unix)] | |
| 246 | { | |
| 247 | let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0; | |
| 248 | alive || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) | |
| 249 | } | |
| 250 | #[cfg(windows)] | |
| 251 | { | |
| 252 | let mut command = Command::new("tasklist"); | |
| 253 | command.args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"]); | |
| 254 | super::native::hide_window(&mut command); | |
| 255 | command.output().is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains(&format!("\"{pid}\""))) | |
| 256 | } | |
| 257 | } | |
| 258 | ||
| 259 | /// Starts a process that outlives the launcher (and the desktop app that | |
| 260 | /// ran it), with no window. Its pid. | |
| 261 | pub fn spawn_detached(mut command: Command) -> Result<u32> { | |
| 262 | #[cfg(windows)] | |
| 263 | { | |
| 264 | use std::os::windows::process::CommandExt; | |
| 265 | const CREATE_NO_WINDOW: u32 = 0x0800_0000; | |
| 266 | const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; | |
| 267 | const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000; | |
| 268 | // The caller's own output (the desktop app's pipe) must not be | |
| 269 | // inherited by a process that outlives this one, or the caller | |
| 270 | // waits for its end until the sandbox stops. | |
| 271 | let _quiet = StdHandlesPrivate::new(); | |
| 272 | command.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB); | |
| 273 | if let Ok(child) = command.spawn() { | |
| 274 | return Ok(child.id()); | |
| 275 | } | |
| 276 | // A job object that allows no breakaway: started inside it. | |
| 277 | command.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP); | |
| 278 | Ok(command.spawn().context("could not start the sandbox")?.id()) | |
| 279 | } | |
| 280 | #[cfg(unix)] | |
| 281 | { | |
| 282 | std::os::unix::process::CommandExt::process_group(&mut command, 0); | |
| 283 | Ok(command.spawn().context("could not start the sandbox")?.id()) | |
| 284 | } | |
| 285 | } | |
| 286 | ||
| 287 | /// While it lives, this process's standard handles are not inheritable. | |
| 288 | #[cfg(windows)] | |
| 289 | struct StdHandlesPrivate { | |
| 290 | handles: Vec<isize>, | |
| 291 | } | |
| 292 | ||
| 293 | #[cfg(windows)] | |
| 294 | unsafe extern "system" { | |
| 295 | fn GetStdHandle(which: u32) -> isize; | |
| 296 | fn SetHandleInformation(handle: isize, mask: u32, flags: u32) -> i32; | |
| 297 | fn GetHandleInformation(handle: isize, flags: *mut u32) -> i32; | |
| 298 | } | |
| 299 | ||
| 300 | #[cfg(windows)] | |
| 301 | impl StdHandlesPrivate { | |
| 302 | const INHERIT: u32 = 1; | |
| 303 | ||
| 304 | fn new() -> StdHandlesPrivate { | |
| 305 | let mut handles = Vec::new(); | |
| 306 | // STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE. | |
| 307 | for which in [-10i32 as u32, -11i32 as u32, -12i32 as u32] { | |
| 308 | let handle = unsafe { GetStdHandle(which) }; | |
| 309 | let mut flags = 0u32; | |
| 310 | if handle != 0 && handle != -1 && unsafe { GetHandleInformation(handle, &mut flags) } != 0 && flags & Self::INHERIT != 0 { | |
| 311 | unsafe { SetHandleInformation(handle, Self::INHERIT, 0) }; | |
| 312 | handles.push(handle); | |
| 313 | } | |
| 314 | } | |
| 315 | StdHandlesPrivate { handles } | |
| 316 | } | |
| 317 | } | |
| 318 | ||
| 319 | #[cfg(windows)] | |
| 320 | impl Drop for StdHandlesPrivate { | |
| 321 | fn drop(&mut self) { | |
| 322 | for handle in &self.handles { | |
| 323 | unsafe { SetHandleInformation(*handle, Self::INHERIT, Self::INHERIT) }; | |
| 324 | } | |
| 325 | } | |
| 326 | } | |
| 327 | ||
| 328 | /// The computer's memory, in MiB. | |
| 329 | pub fn total_memory_mib() -> Option<u64> { | |
| 330 | #[cfg(windows)] | |
| 331 | { | |
| 332 | #[repr(C)] | |
| 333 | struct MemoryStatusEx { | |
| 334 | length: u32, | |
| 335 | load: u32, | |
| 336 | total_phys: u64, | |
| 337 | avail_phys: u64, | |
| 338 | total_page: u64, | |
| 339 | avail_page: u64, | |
| 340 | total_virtual: u64, | |
| 341 | avail_virtual: u64, | |
| 342 | avail_extended: u64, | |
| 343 | } | |
| 344 | unsafe extern "system" { | |
| 345 | fn GlobalMemoryStatusEx(buffer: *mut MemoryStatusEx) -> i32; | |
| 346 | } | |
| 347 | let mut status = MemoryStatusEx { length: std::mem::size_of::<MemoryStatusEx>() as u32, load: 0, total_phys: 0, avail_phys: 0, total_page: 0, avail_page: 0, total_virtual: 0, avail_virtual: 0, avail_extended: 0 }; | |
| 348 | let ok = unsafe { GlobalMemoryStatusEx(&mut status) } != 0; | |
| 349 | ok.then_some(status.total_phys >> 20) | |
| 350 | } | |
| 351 | #[cfg(target_os = "macos")] | |
| 352 | { | |
| 353 | let out = Command::new("sysctl").args(["-n", "hw.memsize"]).output().ok()?; | |
| 354 | String::from_utf8_lossy(&out.stdout).trim().parse::<u64>().ok().map(|b| b >> 20) | |
| 355 | } | |
| 356 | #[cfg(all(unix, not(target_os = "macos")))] | |
| 357 | { | |
| 358 | let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?; | |
| 359 | meminfo.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse::<u64>().ok().map(|kb| kb >> 10) | |
| 360 | } | |
| 361 | } | |
| 362 | ||
| 363 | /// The sandbox's share when none is given: half the CPUs (at most 8) and | |
| 364 | /// a quarter of the memory (2 to 8 GiB, never more than half), and a disk | |
| 365 | /// that may grow to 64 GiB. | |
| 366 | pub fn default_resources(cpus: usize, memory_mib: Option<u64>) -> Resources { | |
| 367 | let cpus = (cpus / 2).clamp(1, 8) as u32; | |
| 368 | let total = memory_mib.unwrap_or(8192); | |
| 369 | let memory_mib = (total / 4).clamp(2048, 8192).min(total / 2).max(1024); | |
| 370 | Resources { cpus, memory_mib, disk_gib: 64 } | |
| 371 | } | |
| 372 | ||
| 373 | fn parse_gib(text: &str) -> Option<u64> { | |
| 374 | super::exec::mib(text).map(|m| m.div_ceil(1024)) | |
| 375 | } | |
| 376 | ||
| 377 | /// The backend for this computer's Linux runner. | |
| 378 | pub fn backend(dir: &Path) -> Box<dyn Backend> { | |
| 379 | let dir = dir.to_path_buf(); | |
| 380 | match std::env::consts::OS { | |
| 381 | "windows" => Box::new(wsl::Wsl { distro: wsl::DISTRO.to_owned(), dir }), | |
| 382 | "macos" => Box::new(vm::DiskVm { hypervisor: vm::Hypervisor::Vfkit, dir }), | |
| 383 | _ => { | |
| 384 | let sysbox = sysbox::SysboxHost { dir: dir.clone() }; | |
| 385 | if sysbox.exists() || sysbox.availability().available { | |
| 386 | Box::new(sysbox) | |
| 387 | } else { | |
| 388 | Box::new(vm::DiskVm { hypervisor: vm::Hypervisor::Qemu, dir }) | |
| 389 | } | |
| 390 | } | |
| 391 | } | |
| 392 | } | |
| 393 | ||
| 394 | // --- The native capability ------------------------------------------------------ | |
| 395 | ||
| 396 | /// What this OS's native sandbox is called, and whether it is here. | |
| 397 | fn native_capability() -> Option<(&'static str, Availability)> { | |
| 398 | match std::env::consts::OS { | |
| 399 | "windows" => Some(("windows", WsbFound::look().availability())), | |
| 400 | "macos" => Some(("macos", MacVm::availability())), | |
| 401 | _ => None, | |
| 402 | } | |
| 403 | } | |
| 404 | ||
| 405 | fn native_dir(dir: &Path) -> PathBuf { | |
| 406 | dir.join("native") | |
| 407 | } | |
| 408 | ||
| 409 | fn native_pid(dir: &Path) -> Option<u32> { | |
| 410 | std::fs::read_to_string(native_dir(dir).join("runner.pid")).ok().and_then(|p| p.trim().parse().ok()) | |
| 411 | } | |
| 412 | ||
| 413 | fn native_running(dir: &Path) -> bool { | |
| 414 | native_pid(dir).is_some_and(process_alive) | |
| 415 | } | |
| 416 | ||
| 417 | /// Registers (once) and starts the runner for native jobs on the computer. | |
| 418 | fn native_up(dir: &Path, saved: &Saved, token: Option<&str>, os: &str) -> Result<()> { | |
| 419 | let folder = native_dir(dir); | |
| 420 | if config::load(&folder).is_err() { | |
| 421 | let (Some(url), Some(token)) = (saved.url.as_deref(), token) else { | |
| 422 | bail!("the {os} runner is not registered: pass --url and --token"); | |
| 423 | }; | |
| 424 | let mut args = vec!["--url".to_owned(), url.to_owned(), "--token".into(), token.to_owned(), "--native".into()]; | |
| 425 | args.extend(["--name".into(), format!("{}-{os}", base_name(saved))]); | |
| 426 | if !saved.labels.is_empty() { | |
| 427 | args.extend(["--labels".into(), saved.labels.join(",")]); | |
| 428 | } | |
| 429 | if let Some(group) = &saved.group { | |
| 430 | args.extend(["--group".into(), group.clone()]); | |
| 431 | } | |
| 432 | let options = Options::parse(&args, &super::VALUE_OPTIONS)?; | |
| 433 | super::register(&options, &folder)?; | |
| 434 | } | |
| 435 | if native_running(dir) { | |
| 436 | return Ok(()); | |
| 437 | } | |
| 438 | let me = std::env::current_exe()?; | |
| 439 | let log_file = std::fs::File::create(folder.join("runner.log"))?; | |
| 440 | let mut command = Command::new(me); | |
| 441 | command.args(["run", "--dir"]).arg(&folder).stdin(Stdio::null()).stdout(log_file.try_clone()?).stderr(log_file); | |
| 442 | let pid = spawn_detached(command)?; | |
| 443 | std::fs::write(folder.join("runner.pid"), pid.to_string())?; | |
| 444 | Ok(()) | |
| 445 | } | |
| 446 | ||
| 447 | fn native_down(dir: &Path, mode: StopMode) { | |
| 448 | let folder = native_dir(dir); | |
| 449 | let Some(pid) = native_pid(dir).filter(|p| process_alive(*p)) else { return }; | |
| 450 | let _ = std::fs::write(folder.join(if mode == StopMode::Drain { "drain" } else { "stop" }), ""); | |
| 451 | let limit = (mode == StopMode::Now).then(|| Instant::now() + Duration::from_secs(90)); | |
| 452 | while process_alive(pid) { | |
| 453 | if limit.is_some_and(|l| Instant::now() > l) { | |
| 454 | #[cfg(windows)] | |
| 455 | let _ = Command::new("taskkill").args(["/F", "/PID", &pid.to_string()]).stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 456 | #[cfg(unix)] | |
| 457 | unsafe { | |
| 458 | libc::kill(pid as libc::pid_t, libc::SIGKILL); | |
| 459 | } | |
| 460 | break; | |
| 461 | } | |
| 462 | std::thread::sleep(Duration::from_millis(500)); | |
| 463 | } | |
| 464 | let _ = std::fs::remove_file(folder.join("runner.pid")); | |
| 465 | } | |
| 466 | ||
| 467 | fn base_name(saved: &Saved) -> String { | |
| 468 | saved.name.clone().unwrap_or_else(|| super::runner_name(&super::hostname())) | |
| 469 | } | |
| 470 | ||
| 471 | // --- Status ----------------------------------------------------------------------- | |
| 472 | ||
| 473 | /// The Linux capability's state from what is known. | |
| 474 | pub fn linux_state(available: bool, exists: bool, running: bool, phase: Option<&str>, guest: Option<&GuestStatus>, error: Option<&str>) -> &'static str { | |
| 475 | if let Some(phase) = phase { | |
| 476 | return if phase == "updating" { "updating" } else { "starting" }; | |
| 477 | } | |
| 478 | if !exists { | |
| 479 | return if available && error.is_none() { "absent" } else if error.is_some() { "error" } else { "absent" }; | |
| 480 | } | |
| 481 | if !running { | |
| 482 | return if error.is_some() { "error" } else { "stopped" }; | |
| 483 | } | |
| 484 | match guest.map(|g| (g.state.as_str(), g.runner.is_some())) { | |
| 485 | // Running, once the runner has registered and says so. | |
| 486 | Some(("running" | "stopping", true)) => "running", | |
| 487 | Some(("error", _)) => "error", | |
| 488 | Some(("stopped", _)) => "stopped", | |
| 489 | _ => "starting", | |
| 490 | } | |
| 491 | } | |
| 492 | ||
| 493 | pub fn status(dir: &Path) -> Status { | |
| 494 | let mut saved = Saved::load(dir); | |
| 495 | // A launcher that died mid-way leaves no phase behind. | |
| 496 | if saved.phase.is_some() && !saved.phase_pid.is_some_and(process_alive) { | |
| 497 | saved.phase = None; | |
| 498 | } | |
| 499 | let backend = backend(dir); | |
| 500 | let available = backend.availability(); | |
| 501 | let exists = backend.exists(); | |
| 502 | let running = exists && backend.running(); | |
| 503 | let guest = if running { backend.guest_status() } else { None }; | |
| 504 | let runner = guest.as_ref().and_then(|g| g.runner.clone()); | |
| 505 | let mut error = saved.error.clone().or_else(|| guest.as_ref().and_then(|g| g.error.clone())); | |
| 506 | if !available.available && error.is_none() { | |
| 507 | error = available.cause.clone(); | |
| 508 | } | |
| 509 | let state = linux_state(available.available, exists, running, saved.phase.as_deref(), guest.as_ref(), error.as_deref()); | |
| 510 | if state == "running" { | |
| 511 | error = None; | |
| 512 | } | |
| 513 | let linux = Capability { | |
| 514 | id: "linux".into(), | |
| 515 | available: available.available, | |
| 516 | state: state.into(), | |
| 517 | isolation: Some(runner.as_ref().map(|r| r.isolation.clone()).unwrap_or_else(|| "nested-container".into())), | |
| 518 | runner_version: runner.as_ref().map(|r| r.version.clone()), | |
| 519 | job: runner.as_ref().and_then(|r| r.job.clone()), | |
| 520 | cause: available.cause.clone(), | |
| 521 | fix: available.fix.clone(), | |
| 522 | }; | |
| 523 | let mut capabilities = vec![linux.clone()]; | |
| 524 | if let Some((os, found)) = native_capability() { | |
| 525 | let folder = native_dir(dir); | |
| 526 | let native_status: Option<RunnerStatus> = std::fs::read(super::status_path(&folder)).ok().and_then(|b| serde_json::from_slice(&b).ok()); | |
| 527 | let registered = config::load(&folder).is_ok(); | |
| 528 | let alive = native_running(dir); | |
| 529 | let state = match (registered, alive) { | |
| 530 | (false, _) => "absent", | |
| 531 | (true, false) => "stopped", | |
| 532 | (true, true) => "running", | |
| 533 | }; | |
| 534 | let (cause, fix) = if saved.no_native && found.available { | |
| 535 | (Some("Turned off with --no-native.".to_owned()), Some("Run `g1t-runner sandbox up` without --no-native.".to_owned())) | |
| 536 | } else { | |
| 537 | (found.cause.clone(), found.fix.clone()) | |
| 538 | }; | |
| 539 | capabilities.push(Capability { | |
| 540 | id: os.into(), | |
| 541 | available: found.available && !saved.no_native, | |
| 542 | state: state.into(), | |
| 543 | isolation: Some("vm".into()), | |
| 544 | runner_version: native_status.as_ref().filter(|_| alive).map(|s| s.version.clone()), | |
| 545 | job: native_status.filter(|_| alive).and_then(|s| s.job), | |
| 546 | cause, | |
| 547 | fix, | |
| 548 | }); | |
| 549 | } | |
| 550 | Status { | |
| 551 | schema: SCHEMA, | |
| 552 | state: linux.state, | |
| 553 | backend: Some(backend.name().to_owned()), | |
| 554 | runner_version: linux.runner_version, | |
| 555 | launcher_version: VERSION.to_owned(), | |
| 556 | // What the running image says it is, else what was installed. | |
| 557 | image_version: guest.as_ref().and_then(|g| g.image_version.clone()).or_else(|| saved.image_version.clone()), | |
| 558 | isolation: linux.isolation, | |
| 559 | job: linux.job, | |
| 560 | error, | |
| 561 | resources: saved.resources, | |
| 562 | capabilities, | |
| 563 | state_dir: dir.display().to_string(), | |
| 564 | } | |
| 565 | } | |
| 566 | ||
| 567 | fn print(status: &Status, json: bool) { | |
| 568 | if json { | |
| 569 | println!("{}", serde_json::to_string_pretty(status).unwrap_or_default()); | |
| 570 | return; | |
| 571 | } | |
| 572 | println!("Sandbox: {} ({})", status.state, status.backend.as_deref().unwrap_or("none")); | |
| 573 | for capability in &status.capabilities { | |
| 574 | let mut line = format!(" {:8} {}", capability.id, if capability.available { capability.state.as_str() } else { "unavailable" }); | |
| 575 | if let Some(job) = &capability.job { | |
| 576 | line.push_str(&format!(", running {} in {}", job.name, job.repo)); | |
| 577 | } | |
| 578 | println!("{line}"); | |
| 579 | if !capability.available { | |
| 580 | if let Some(cause) = &capability.cause { | |
| 581 | println!(" {cause}"); | |
| 582 | } | |
| 583 | if let Some(fix) = &capability.fix { | |
| 584 | println!(" {fix}"); | |
| 585 | } | |
| 586 | } | |
| 587 | } | |
| 588 | if let Some(error) = &status.error { | |
| 589 | println!("Error: {error}"); | |
| 590 | } | |
| 591 | } | |
| 592 | ||
| 593 | // --- Commands ----------------------------------------------------------------------- | |
| 594 | ||
| 595 | const HELP: &str = "g1t-runner sandbox: self-hosted runners on your own computer, sandboxed. | |
| 596 | ||
| 597 | g1t-runner sandbox up [--url https://g1t.sh --token <registration token>] | |
| 598 | [--name NAME] [--labels a,b] [--group GROUP] | |
| 599 | [--cpus N] [--memory 8g] [--disk 64g] [--no-native | --native] [--json] | |
| 600 | g1t-runner sandbox down [--drain] [--delete] [--json] | |
| 601 | g1t-runner sandbox status [--json] | |
| 602 | g1t-runner sandbox update [--json] | |
| 603 | ||
| 604 | Linux jobs run in a Linux VM of the runner's own (a WSL distribution on | |
| 605 | Windows, a vfkit VM on macOS; on Linux, Sysbox, or a QEMU VM), each in a | |
| 606 | fresh container inside it. Windows jobs run each in a Windows Sandbox of | |
| 607 | its own (Windows 11 Pro, Enterprise or Education). Nothing a job runs | |
| 608 | reaches this computer's files, programs or Docker. | |
| 609 | ||
| 610 | The first `up` needs --url and --token (make a token under Settings, | |
| 611 | Runners). `down` stops now, cutting a running job short; --drain waits | |
| 612 | for it. `update` drains, swaps in the newest signed image, and starts | |
| 613 | again, keeping the registration. Every command takes --state-dir DIR | |
| 614 | (default ~/.g1t-runner/sandbox). | |
| 615 | Docs: https://docs.g1t.sh/guides/self-hosted-runners/#sandbox-on-your-computer"; | |
| 616 | ||
| 617 | const SANDBOX_VALUES: &[&str] = &[ | |
| 618 | "url", | |
| 619 | "token", | |
| 620 | "name", | |
| 621 | "labels", | |
| 622 | "group", | |
| 623 | "cpus", | |
| 624 | "memory", | |
| 625 | "disk", | |
| 626 | "state-dir", | |
| 627 | "image-file", | |
| 628 | "runner-image", | |
| 629 | "runner-image-file", | |
| 630 | "wait", | |
| 631 | ]; | |
| 632 | ||
| 633 | /// `g1t-runner sandbox <command>`: the process's exit code. | |
| 634 | pub fn main(args: &[String]) -> i32 { | |
| 635 | if args.first().map(String::as_str) == Some("guest") { | |
| 636 | return match guest::main(&args[1..]) { | |
| 637 | Ok(()) => 0, | |
| 638 | Err(error) => { | |
| 639 | eprintln!("g1t-runner: {error:#}"); | |
| 640 | 1 | |
| 641 | } | |
| 642 | }; | |
| 643 | } | |
| 644 | let options = match Options::parse(args.get(1..).unwrap_or_default(), SANDBOX_VALUES) { | |
| 645 | Ok(options) => options, | |
| 646 | Err(error) => { | |
| 647 | eprintln!("g1t-runner: {error:#}"); | |
| 648 | return 2; | |
| 649 | } | |
| 650 | }; | |
| 651 | let json = options.flag("json"); | |
| 652 | let dir = options.value("state-dir").map(PathBuf::from).unwrap_or_else(|| config::folder(None).join("sandbox")); | |
| 653 | let outcome = match args.first().map(String::as_str) { | |
| 654 | Some("up") => up(&options, &dir), | |
| 655 | Some("down") => down(&options, &dir), | |
| 656 | Some("update") => update_sandbox(&options, &dir), | |
| 657 | Some("status") => Ok(()), | |
| 658 | _ => { | |
| 659 | println!("{HELP}"); | |
| 660 | return 0; | |
| 661 | } | |
| 662 | }; | |
| 663 | let mut status = status(&dir); | |
| 664 | match outcome { | |
| 665 | Ok(()) => { | |
| 666 | print(&status, json); | |
| 667 | 0 | |
| 668 | } | |
| 669 | Err(error) => { | |
| 670 | status.error = Some(format!("{error:#}")); | |
| 671 | if json { | |
| 672 | print(&status, true); | |
| 673 | } else { | |
| 674 | eprintln!("g1t-runner: {error:#}"); | |
| 675 | } | |
| 676 | 1 | |
| 677 | } | |
| 678 | } | |
| 679 | } | |
| 680 | ||
| 681 | /// Runs `step` as a phase (`starting`, `updating`) that `status` reports, | |
| 682 | /// keeping what went wrong for it. | |
| 683 | fn in_phase(dir: &Path, phase: &str, step: impl FnOnce(&mut Saved) -> Result<()>) -> Result<()> { | |
| 684 | let mut saved = Saved::load(dir); | |
| 685 | saved.phase = Some(phase.to_owned()); | |
| 686 | saved.phase_pid = Some(std::process::id()); | |
| 687 | saved.error = None; | |
| 688 | saved.save(dir)?; | |
| 689 | let outcome = step(&mut saved); | |
| 690 | saved.phase = None; | |
| 691 | saved.phase_pid = None; | |
| 692 | saved.error = outcome.as_ref().err().map(|e| format!("{e:#}")); | |
| 693 | saved.save(dir)?; | |
| 694 | outcome | |
| 695 | } | |
| 696 | ||
| 697 | fn up(options: &Options, dir: &Path) -> Result<()> { | |
| 698 | let mut saved = Saved::load(dir); | |
| 699 | if let Some(url) = options.value("url") { | |
| 700 | saved.url = Some(url.trim_end_matches('/').to_owned()); | |
| 701 | } | |
| 702 | if let Some(name) = options.value("name") { | |
| 703 | saved.name = Some(super::runner_name(name)); | |
| 704 | } | |
| 705 | if let Some(labels) = options.value("labels") { | |
| 706 | saved.labels = labels.split(',').map(|l| l.trim().to_owned()).filter(|l| !l.is_empty()).collect(); | |
| 707 | } | |
| 708 | if let Some(group) = options.value("group") { | |
| 709 | saved.group = Some(group.to_owned()); | |
| 710 | } | |
| 711 | let defaults = default_resources(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2), total_memory_mib()); | |
| 712 | let mut resources = if saved.resources == Resources::default() { defaults } else { saved.resources }; | |
| 713 | if let Some(cpus) = options.value("cpus") { | |
| 714 | resources.cpus = cpus.parse().ok().filter(|n| *n > 0).ok_or_else(|| anyhow!("--cpus takes a number of CPUs, not {cpus}"))?; | |
| 715 | } | |
| 716 | if let Some(memory) = options.value("memory") { | |
| 717 | resources.memory_mib = super::exec::mib(memory).filter(|m| *m >= 1024).ok_or_else(|| anyhow!("--memory takes a size of 1g or more, such as 8g, not {memory}"))?; | |
| 718 | } | |
| 719 | if let Some(disk) = options.value("disk") { | |
| 720 | resources.disk_gib = parse_gib(disk).filter(|g| *g >= 16).ok_or_else(|| anyhow!("--disk takes a size of 16g or more, such as 64g, not {disk}"))?; | |
| 721 | } | |
| 722 | saved.resources = resources; | |
| 723 | // Kept until said otherwise. | |
| 724 | if options.flag("no-native") { | |
| 725 | saved.no_native = true; | |
| 726 | } else if options.flag("native") { | |
| 727 | saved.no_native = false; | |
| 728 | } | |
| 729 | if let Some(image) = options.value("runner-image") { | |
| 730 | saved.runner_image = Some(image.to_owned()); | |
| 731 | } | |
| 732 | saved.save(dir)?; | |
| 733 | let token = options.value("token").map(str::to_owned); | |
| 734 | ||
| 735 | let backend = backend(dir); | |
| 736 | let found = backend.availability(); | |
| 737 | let linux = if found.available { | |
| 738 | in_phase(dir, "starting", |saved| { | |
| 739 | let fresh = !backend.exists(); | |
| 740 | if fresh { | |
| 741 | let local = obtain_image(options, saved, backend.as_ref(), dir)?; | |
| 742 | backend.create(&local, &saved.resources)?; | |
| 743 | saved.image_version = Some(local.version.clone()); | |
| 744 | if local.runner_image.is_some() { | |
| 745 | saved.runner_image = local.runner_image.clone(); | |
| 746 | } | |
| 747 | // An update that stopped half-way left the registration here. | |
| 748 | let carried = dir.join("carry").join("config.json"); | |
| 749 | if let Ok(bytes) = std::fs::read(&carried) { | |
| 750 | backend.import_state(&bytes)?; | |
| 751 | let _ = std::fs::remove_file(&carried); | |
| 752 | } | |
| 753 | } | |
| 754 | if let Some(tar) = options.value("runner-image-file") { | |
| 755 | backend.load_image(Path::new(tar))?; | |
| 756 | } | |
| 757 | let Some(runner_image) = saved.runner_image.clone() else { | |
| 758 | bail!("there is no runner image for the sandbox: this build is not a release, so pass --runner-image or --runner-image-file"); | |
| 759 | }; | |
| 760 | backend.configure(&Settings { | |
| 761 | url: saved.url.clone(), | |
| 762 | token: token.clone(), | |
| 763 | name: Some(format!("{}-linux", base_name(saved))), | |
| 764 | labels: saved.labels.clone(), | |
| 765 | group: saved.group.clone(), | |
| 766 | cpus: saved.resources.cpus, | |
| 767 | memory_mib: saved.resources.memory_mib, | |
| 768 | runner_image, | |
| 769 | image_version: saved.image_version.clone().unwrap_or_default(), | |
| 770 | stop: None, | |
| 771 | })?; | |
| 772 | backend.start()?; | |
| 773 | wait_running(backend.as_ref(), Duration::from_secs(if fresh { 600 } else { 180 })) | |
| 774 | }) | |
| 775 | } else { | |
| 776 | Err(anyhow!("{} {}", found.cause.unwrap_or_default(), found.fix.unwrap_or_default())) | |
| 777 | }; | |
| 778 | let native = match native_capability() { | |
| 779 | Some((os, found)) if found.available && !saved.no_native => native_up(dir, &Saved::load(dir), token.as_deref(), os).map(|()| true), | |
| 780 | _ => Ok(false), | |
| 781 | }; | |
| 782 | match (linux, native) { | |
| 783 | (Ok(()), Ok(_)) => Ok(()), | |
| 784 | (Err(linux), Ok(true)) => Err(linux.context("Windows jobs are sandboxed and running, but Linux jobs are not")), | |
| 785 | (Err(linux), _) => Err(linux), | |
| 786 | (Ok(()), Err(native)) => Err(native.context("Linux jobs are sandboxed and running, but jobs native to this OS are not")), | |
| 787 | } | |
| 788 | } | |
| 789 | ||
| 790 | /// Waits until the sandbox's runner is registered and asking for work (it | |
| 791 | /// has written its status), or says what went wrong. | |
| 792 | fn wait_running(backend: &dyn Backend, limit: Duration) -> Result<()> { | |
| 793 | let until = Instant::now() + limit; | |
| 794 | loop { | |
| 795 | if let Some(status) = backend.guest_status() { | |
| 796 | match status.state.as_str() { | |
| 797 | "running" if status.runner.as_ref().is_some_and(|r| r.state != "stopped") => return Ok(()), | |
| 798 | "error" => bail!("{}", status.error.unwrap_or_else(|| "the sandbox stopped".into())), | |
| 799 | _ => {} | |
| 800 | } | |
| 801 | } | |
| 802 | if Instant::now() > until { | |
| 803 | bail!("the sandbox did not start within {} seconds; `g1t-runner sandbox status` says how it is now", limit.as_secs()); | |
| 804 | } | |
| 805 | std::thread::sleep(Duration::from_secs(1)); | |
| 806 | } | |
| 807 | } | |
| 808 | ||
| 809 | fn obtain_image(options: &Options, saved: &Saved, backend: &dyn Backend, dir: &Path) -> Result<image::Local> { | |
| 810 | let keys = backend.image_keys(); | |
| 811 | if let Some(file) = options.value("image-file") { | |
| 812 | log(&format!("Using {file}, which is not a release: it is not checked against g1t's signature.")); | |
| 813 | return image::local(&keys, Path::new(file), saved.runner_image.clone()); | |
| 814 | } | |
| 815 | if keys.is_empty() { | |
| 816 | // Sysbox on Linux: only the runner's image, named in the release. | |
| 817 | let manifest = image::latest(saved.site()); | |
| 818 | return Ok(image::Local { | |
| 819 | version: manifest.as_ref().map(|m| m.version.clone()).unwrap_or_else(|_| VERSION.to_owned()), | |
| 820 | files: Default::default(), | |
| 821 | runner_image: manifest.ok().and_then(|m| m.sandbox).and_then(|s| s.runner_image).or_else(|| saved.runner_image.clone()), | |
| 822 | }); | |
| 823 | } | |
| 824 | let manifest = image::latest(saved.site())?; | |
| 825 | image::obtain(saved.site(), &manifest, &keys, &dir.join("images"), &|line| log(line)) | |
| 826 | } | |
| 827 | ||
| 828 | fn down(options: &Options, dir: &Path) -> Result<()> { | |
| 829 | let mode = if options.flag("drain") { StopMode::Drain } else { StopMode::Now }; | |
| 830 | native_down(dir, mode); | |
| 831 | let backend = backend(dir); | |
| 832 | if options.flag("delete") { | |
| 833 | backend.remove()?; | |
| 834 | let _ = std::fs::remove_dir_all(dir.join("images")); | |
| 835 | let _ = std::fs::remove_dir_all(native_dir(dir)); | |
| 836 | let _ = std::fs::remove_dir_all(dir.join("carry")); | |
| 837 | let mut saved = Saved::load(dir); | |
| 838 | saved.image_version = None; | |
| 839 | saved.error = None; | |
| 840 | saved.save(dir)?; | |
| 841 | log("Deleted the sandbox. Its runners are still listed on g1t until you remove them there."); | |
| 842 | return Ok(()); | |
| 843 | } | |
| 844 | if backend.exists() { | |
| 845 | backend.stop(mode)?; | |
| 846 | } | |
| 847 | let mut saved = Saved::load(dir); | |
| 848 | saved.error = None; | |
| 849 | saved.save(dir) | |
| 850 | } | |
| 851 | ||
| 852 | fn update_sandbox(options: &Options, dir: &Path) -> Result<()> { | |
| 853 | let backend = backend(dir); | |
| 854 | if !backend.exists() { | |
| 855 | bail!("there is no sandbox to update: run `g1t-runner sandbox up`"); | |
| 856 | } | |
| 857 | let saved = Saved::load(dir); | |
| 858 | let local = if options.value("image-file").is_some() { | |
| 859 | obtain_image(options, &saved, backend.as_ref(), dir)? | |
| 860 | } else { | |
| 861 | let manifest = image::latest(saved.site())?; | |
| 862 | if saved.image_version.as_deref().is_some_and(|current| !super::update::newer(&manifest.version, current)) { | |
| 863 | log(&format!("The sandbox's image is the newest, {}.", manifest.version)); | |
| 864 | return Ok(()); | |
| 865 | } | |
| 866 | let keys = backend.image_keys(); | |
| 867 | if keys.is_empty() { | |
| 868 | obtain_image(options, &saved, backend.as_ref(), dir)? | |
| 869 | } else { | |
| 870 | image::obtain(saved.site(), &manifest, &keys, &dir.join("images"), &|line| log(line))? | |
| 871 | } | |
| 872 | }; | |
| 873 | in_phase(dir, "updating", |saved| { | |
| 874 | let was_running = backend.running(); | |
| 875 | if was_running { | |
| 876 | log("Draining: the runner finishes the job it is running, then stops."); | |
| 877 | backend.stop(StopMode::Drain)?; | |
| 878 | } | |
| 879 | // The registration, kept on the computer until the new sandbox has it. | |
| 880 | let carried = dir.join("carry").join("config.json"); | |
| 881 | if let Some(bytes) = backend.export_state()? { | |
| 882 | std::fs::create_dir_all(dir.join("carry"))?; | |
| 883 | write_private(&carried, &bytes)?; | |
| 884 | } | |
| 885 | backend.remove_system()?; | |
| 886 | backend.create(&local, &saved.resources)?; | |
| 887 | if let Ok(bytes) = std::fs::read(&carried) { | |
| 888 | backend.import_state(&bytes)?; | |
| 889 | let _ = std::fs::remove_file(&carried); | |
| 890 | } | |
| 891 | if let Some(tar) = options.value("runner-image-file") { | |
| 892 | backend.load_image(Path::new(tar))?; | |
| 893 | } | |
| 894 | saved.image_version = Some(local.version.clone()); | |
| 895 | if local.runner_image.is_some() { | |
| 896 | saved.runner_image = local.runner_image.clone(); | |
| 897 | } | |
| 898 | backend.configure(&Settings { | |
| 899 | runner_image: saved.runner_image.clone().unwrap_or_default(), | |
| 900 | image_version: local.version.clone(), | |
| 901 | cpus: saved.resources.cpus, | |
| 902 | memory_mib: saved.resources.memory_mib, | |
| 903 | url: saved.url.clone(), | |
| 904 | name: Some(format!("{}-linux", base_name(saved))), | |
| 905 | labels: saved.labels.clone(), | |
| 906 | group: saved.group.clone(), | |
| 907 | ..Settings::default() | |
| 908 | })?; | |
| 909 | if was_running { | |
| 910 | backend.start()?; | |
| 911 | wait_running(backend.as_ref(), Duration::from_secs(600))?; | |
| 912 | } | |
| 913 | image::prune(&dir.join("images"), &local.version); | |
| 914 | if local.version == "local" { | |
| 915 | log("The sandbox is on the image from --image-file."); | |
| 916 | } else { | |
| 917 | log(&format!("The sandbox is on image {}.", local.version)); | |
| 918 | } | |
| 919 | Ok(()) | |
| 920 | }) | |
| 921 | } | |
| 922 | ||
| 923 | #[cfg(test)] | |
| 924 | mod tests { | |
| 925 | use super::*; | |
| 926 | ||
| 927 | #[test] | |
| 928 | fn the_default_share_leaves_the_computer_room() { | |
| 929 | assert_eq!(default_resources(16, Some(65536)), Resources { cpus: 8, memory_mib: 8192, disk_gib: 64 }); | |
| 930 | assert_eq!(default_resources(8, Some(16384)), Resources { cpus: 4, memory_mib: 4096, disk_gib: 64 }); | |
| 931 | assert_eq!(default_resources(4, Some(8192)), Resources { cpus: 2, memory_mib: 2048, disk_gib: 64 }); | |
| 932 | // Never more than half a small machine's memory. | |
| 933 | assert_eq!(default_resources(2, Some(3072)).memory_mib, 1536); | |
| 934 | assert_eq!(default_resources(1, None).cpus, 1); | |
| 935 | assert_eq!(parse_gib("64g"), Some(64)); | |
| 936 | assert_eq!(parse_gib("100000m"), Some(98)); | |
| 937 | } | |
| 938 | ||
| 939 | #[test] | |
| 940 | fn the_state_says_what_a_person_needs_to_know() { | |
| 941 | let running = GuestStatus { state: "running".into(), runner: Some(RunnerStatus { state: "idle".into(), ..RunnerStatus::default() }), ..GuestStatus::default() }; | |
| 942 | let unregistered = GuestStatus { state: "running".into(), ..GuestStatus::default() }; | |
| 943 | let failed = GuestStatus { state: "error".into(), error: Some("x".into()), ..GuestStatus::default() }; | |
| 944 | assert_eq!(linux_state(true, false, false, None, None, None), "absent"); | |
| 945 | assert_eq!(linux_state(false, false, false, None, None, Some("WSL is not installed.")), "error"); | |
| 946 | assert_eq!(linux_state(true, true, false, None, None, None), "stopped"); | |
| 947 | assert_eq!(linux_state(true, true, true, None, None, None), "starting"); | |
| 948 | assert_eq!(linux_state(true, true, true, None, Some(&running), None), "running"); | |
| 949 | assert_eq!(linux_state(true, true, true, None, Some(&unregistered), None), "starting"); | |
| 950 | assert_eq!(linux_state(true, true, true, None, Some(&failed), None), "error"); | |
| 951 | assert_eq!(linux_state(true, true, true, Some("updating"), Some(&running), None), "updating"); | |
| 952 | assert_eq!(linux_state(true, false, false, Some("starting"), None, None), "starting"); | |
| 953 | } | |
| 954 | ||
| 955 | #[test] | |
| 956 | fn the_status_contract_holds_its_fields() { | |
| 957 | let status = Status { | |
| 958 | schema: SCHEMA, | |
| 959 | state: "running".into(), | |
| 960 | backend: Some("wsl2".into()), | |
| 961 | launcher_version: VERSION.into(), | |
| 962 | capabilities: vec![Capability { id: "linux".into(), available: true, state: "running".into(), ..Capability::default() }], | |
| 963 | ..Status::default() | |
| 964 | }; | |
| 965 | let value = serde_json::to_value(&status).unwrap(); | |
| 966 | for field in ["schema", "state", "backend", "runner_version", "launcher_version", "image_version", "isolation", "job", "error", "resources", "capabilities", "state_dir"] { | |
| 967 | assert!(value.get(field).is_some(), "{field}"); | |
| 968 | } | |
| 969 | for field in ["id", "available", "state", "isolation", "runner_version", "job", "cause", "fix"] { | |
| 970 | assert!(value["capabilities"][0].get(field).is_some(), "{field}"); | |
| 971 | } | |
| 972 | assert_eq!(value["resources"]["memory_mib"], 0); | |
| 973 | } | |
| 974 | ||
| 975 | #[test] | |
| 976 | fn a_status_with_nothing_made_is_absent_and_says_where() { | |
| 977 | let dir = std::env::temp_dir().join(format!("g1t-sandbox-status-{}", std::process::id())); | |
| 978 | let saved = Saved { phase: Some("updating".into()), phase_pid: Some(u32::MAX - 1), ..Saved::default() }; | |
| 979 | saved.save(&dir).unwrap(); | |
| 980 | // A launcher that died mid-update leaves no phase in the status. | |
| 981 | assert_ne!(status(&dir).state, "updating"); | |
| 982 | assert_eq!(status(&dir).state_dir, dir.display().to_string()); | |
| 983 | let _ = std::fs::remove_dir_all(&dir); | |
| 984 | } | |
| 985 | ||
| 986 | #[test] | |
| 987 | fn this_process_is_alive() { | |
| 988 | assert!(process_alive(std::process::id())); | |
| 989 | assert!(!process_alive(u32::MAX - 1)); | |
| 990 | assert!(total_memory_mib().unwrap_or(1) > 0); | |
| 991 | } | |
| 992 | } |