Skip to content
1,222 linesCodeBlameRaw
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//! [--only linux,windows] [--no-native | --native] [--state-dir DIR] [--json]
9//! [--image-file PATH] [--runner-image REF] [--runner-image-file TAR]
10//! g1t-runner sandbox down [--only linux,windows] [--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//! Each capability starts and stops on its own. `up --only windows` turns
39//! Windows jobs on and starts them, leaving Linux jobs as they are; `down
40//! --only windows` stops them and keeps them off, so a later `up` (with no
41//! `--only`) starts only what is on. `down` with no `--only` stops
42//! everything and turns nothing off. `--no-native` and `--native` turn the
43//! OS's own capability off and on, as `--only` does.
44//!
45//! ## `status --json`
46//!
47//! The desktop app runs `g1t-runner sandbox status --json` and shows what
48//! it says. One JSON object on standard output, exit code 0 whenever the
49//! status could be read (a sandbox in `error` included):
50//!
51//! ```json
52//! {
53//! "schema": 1,
54//! "state": "running",
55//! "backend": "wsl2",
56//! "runner_version": "0.3.0",
57//! "launcher_version": "0.3.0",
58//! "image_version": "0.3.0",
59//! "isolation": "nested-container",
60//! "job": { "kind": "workflow", "id": "job_…", "name": "build", "repo": "acme/web" },
61//! "error": null,
62//! "resources": { "cpus": 4, "memory_mib": 8192, "disk_gib": 64 },
63//! "capabilities": [
64//! { "id": "linux", "available": true, "state": "running", "isolation": "nested-container",
65//! "runner_version": "0.3.0", "job": null, "cause": null, "fix": null, "turned_off": false },
66//! { "id": "windows", "available": false, "state": "absent", "isolation": "vm",
67//! "runner_version": null, "job": null, "turned_off": false,
68//! "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
69//! "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, …" }
70//! ],
71//! "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox"
72//! }
73//! ```
74//!
75//! The top-level fields are the Linux VM's. `state` is one of `absent`
76//! (never made, or deleted), `stopped`, `starting`, `running`, `updating`
77//! or `error`; `error` is a sentence for a person, set whenever something
78//! is wrong, and `cause`/`fix` say why a capability is unavailable, or for
79//! a native one in `error` why it didn't start, and what to do.
80//! `turned_off` is true for a capability someone stopped (`down --only`,
81//! `--no-native`), whose state is then `stopped`: stopped by a person, not
82//! failing. `job` is what the runner is running now, or null. Fields
83//! are only ever added; `schema` changes if one changes meaning. `up`,
84//! `down` and `update` print the same object with `--json` when they are
85//! done, and exit 1 with `error` set when they fail.
86
87// The supervisor runs inside the Linux VM; elsewhere only its builders
88// (tested everywhere) and its commands' parsing are used.
89#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
90pub mod guest;
91mod image;
92mod sysbox;
93#[cfg_attr(not(unix), allow(dead_code))]
94mod vm;
95mod wsl;
96
97use std::path::{Path, PathBuf};
98use std::process::{Command, Stdio};
99use std::time::{Duration, Instant};
100
101use anyhow::{Context, Result, anyhow, bail};
102use serde::{Deserialize, Serialize};
103
104use super::native::{Availability, MacVm, WsbFound};
105use super::{JobStatus, Options, RunnerStatus, VERSION, config, log};
106use guest::{GuestStatus, Settings};
107
108/// What `status --json`'s shape is.
109pub const SCHEMA: u32 = 1;
110/// The site whose releases the sandbox's images come from when none was
111/// given.
112pub const DEFAULT_SITE: &str = "https://g1t.sh";
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum StopMode {
116 /// After the running job, if any.
117 Drain,
118 /// Now: the running job is cut short, and g1t told so.
119 Now,
120}
121
122/// The VM's (or the runner container's) share of the computer.
123#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
124pub struct Resources {
125 pub cpus: u32,
126 pub memory_mib: u64,
127 pub disk_gib: u64,
128}
129
130/// One kind of sandbox that holds the Linux runner.
131pub trait Backend {
132 /// What `status` calls it: `wsl2`, `vfkit`, `qemu` or `sysbox`.
133 fn name(&self) -> &'static str;
134 /// The release files it is made from.
135 fn image_keys(&self) -> Vec<String>;
136 fn availability(&self) -> Availability;
137 fn exists(&self) -> bool;
138 fn running(&self) -> bool;
139 fn create(&self, local: &image::Local, resources: &Resources) -> Result<()>;
140 fn configure(&self, settings: &Settings) -> Result<()>;
141 /// A runner image from a file, for a build that is not released.
142 fn load_image(&self, tar: &Path) -> Result<()>;
143 fn start(&self) -> Result<()>;
144 fn guest_status(&self) -> Option<GuestStatus>;
145 fn stop(&self, mode: StopMode) -> Result<()>;
146 /// The runner's registration, when the sandbox's system and its state
147 /// are one (WSL), to carry across an update.
148 fn export_state(&self) -> Result<Option<Vec<u8>>>;
149 fn import_state(&self, bytes: &[u8]) -> Result<()>;
150 /// Removes the sandbox's system, keeping its state where that is apart.
151 fn remove_system(&self) -> Result<()> {
152 self.remove()
153 }
154 /// Removes the sandbox entirely.
155 fn remove(&self) -> Result<()>;
156}
157
158/// What the launcher keeps in `<state>/sandbox.json`.
159#[derive(Clone, Debug, Default, Serialize, Deserialize)]
160pub struct Saved {
161 #[serde(default)]
162 pub url: Option<String>,
163 /// The runners' name before `-linux` / `-windows`.
164 #[serde(default)]
165 pub name: Option<String>,
166 #[serde(default)]
167 pub labels: Vec<String>,
168 #[serde(default)]
169 pub group: Option<String>,
170 #[serde(default)]
171 pub resources: Resources,
172 #[serde(default)]
173 pub image_version: Option<String>,
174 #[serde(default)]
175 pub runner_image: Option<String>,
176 /// The capabilities turned off (`down --only`, `--no-native`), kept
177 /// until `up --only` (or `--native`) turns them on again.
178 #[serde(default)]
179 pub off: Vec<String>,
180 /// From a launcher before `off`: the native capability turned off.
181 #[serde(default, skip_serializing)]
182 pub no_native: bool,
183 /// Why the native capability's runner didn't start, until it does.
184 #[serde(default)]
185 pub native_error: Option<String>,
186 /// `starting` or `updating`, while a launcher (`phase_pid`) does it.
187 #[serde(default)]
188 pub phase: Option<String>,
189 #[serde(default)]
190 pub phase_pid: Option<u32>,
191 /// What went wrong last, for `status`.
192 #[serde(default)]
193 pub error: Option<String>,
194}
195
196impl Saved {
197 fn path(dir: &Path) -> PathBuf {
198 dir.join("sandbox.json")
199 }
200
201 pub fn load(dir: &Path) -> Saved {
202 let mut saved: Saved = std::fs::read(Saved::path(dir)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
203 // An earlier launcher's `--no-native`, said the way this one says it.
204 if std::mem::take(&mut saved.no_native)
205 && let Some(native) = native_id()
206 {
207 saved.turn(native, false);
208 }
209 saved
210 }
211
212 /// Whether a capability was turned off.
213 pub fn is_off(&self, id: &str) -> bool {
214 self.off.iter().any(|o| o == id)
215 }
216
217 /// Turns a capability on or off, until said otherwise.
218 pub fn turn(&mut self, id: &str, on: bool) {
219 self.off.retain(|o| o != id);
220 if !on {
221 self.off.push(id.to_owned());
222 self.off.sort();
223 }
224 }
225
226 pub fn save(&self, dir: &Path) -> Result<()> {
227 std::fs::create_dir_all(dir).with_context(|| format!("could not make {}", dir.display()))?;
228 write_private(&Saved::path(dir), &serde_json::to_vec_pretty(self)?)
229 }
230
231 fn site(&self) -> &str {
232 self.url.as_deref().unwrap_or(DEFAULT_SITE)
233 }
234}
235
236/// What `status --json` prints (see the module's documentation).
237#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
238pub struct Status {
239 pub schema: u32,
240 pub state: String,
241 pub backend: Option<String>,
242 pub runner_version: Option<String>,
243 pub launcher_version: String,
244 pub image_version: Option<String>,
245 pub isolation: Option<String>,
246 pub job: Option<JobStatus>,
247 pub error: Option<String>,
248 pub resources: Resources,
249 pub capabilities: Vec<Capability>,
250 pub state_dir: String,
251}
252
253/// One kind of job this computer can run sandboxed.
254#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
255pub struct Capability {
256 /// `linux`, `windows` or `macos`.
257 pub id: String,
258 pub available: bool,
259 pub state: String,
260 pub isolation: Option<String>,
261 pub runner_version: Option<String>,
262 pub job: Option<JobStatus>,
263 pub cause: Option<String>,
264 pub fix: Option<String>,
265 /// Someone stopped it (`down --only`, `--no-native`): `stopped` until
266 /// `up --only` turns it on again. Older launchers leave it out.
267 pub turned_off: bool,
268}
269
270pub fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
271 let temp = path.with_extension("new");
272 std::fs::write(&temp, bytes).with_context(|| format!("could not write {}", temp.display()))?;
273 #[cfg(unix)]
274 {
275 use std::os::unix::fs::PermissionsExt;
276 std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?;
277 }
278 std::fs::rename(&temp, path).with_context(|| format!("could not write {}", path.display()))?;
279 Ok(())
280}
281
282// --- The computer -------------------------------------------------------------
283
284/// Whether a process is still running.
285pub fn process_alive(pid: u32) -> bool {
286 #[cfg(unix)]
287 {
288 let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
289 alive || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
290 }
291 #[cfg(windows)]
292 {
293 let mut command = Command::new("tasklist");
294 command.args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"]);
295 super::native::hide_window(&mut command);
296 command.output().is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains(&format!("\"{pid}\"")))
297 }
298}
299
300/// Starts a process that outlives the launcher (and the desktop app that
301/// ran it), with no window. Its pid.
302pub fn spawn_detached(mut command: Command) -> Result<u32> {
303 #[cfg(windows)]
304 {
305 use std::os::windows::process::CommandExt;
306 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
307 const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
308 const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000;
309 // The caller's own output (the desktop app's pipe) must not be
310 // inherited by a process that outlives this one, or the caller
311 // waits for its end until the sandbox stops.
312 let _quiet = StdHandlesPrivate::new();
313 command.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB);
314 if let Ok(child) = command.spawn() {
315 return Ok(child.id());
316 }
317 // A job object that allows no breakaway: started inside it.
318 command.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP);
319 Ok(command.spawn().context("could not start the sandbox")?.id())
320 }
321 #[cfg(unix)]
322 {
323 std::os::unix::process::CommandExt::process_group(&mut command, 0);
324 Ok(command.spawn().context("could not start the sandbox")?.id())
325 }
326}
327
328/// While it lives, this process's standard handles are not inheritable.
329#[cfg(windows)]
330struct StdHandlesPrivate {
331 handles: Vec<isize>,
332}
333
334#[cfg(windows)]
335unsafe extern "system" {
336 fn GetStdHandle(which: u32) -> isize;
337 fn SetHandleInformation(handle: isize, mask: u32, flags: u32) -> i32;
338 fn GetHandleInformation(handle: isize, flags: *mut u32) -> i32;
339}
340
341#[cfg(windows)]
342impl StdHandlesPrivate {
343 const INHERIT: u32 = 1;
344
345 fn new() -> StdHandlesPrivate {
346 let mut handles = Vec::new();
347 // STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE.
348 for which in [-10i32 as u32, -11i32 as u32, -12i32 as u32] {
349 let handle = unsafe { GetStdHandle(which) };
350 let mut flags = 0u32;
351 if handle != 0 && handle != -1 && unsafe { GetHandleInformation(handle, &mut flags) } != 0 && flags & Self::INHERIT != 0 {
352 unsafe { SetHandleInformation(handle, Self::INHERIT, 0) };
353 handles.push(handle);
354 }
355 }
356 StdHandlesPrivate { handles }
357 }
358}
359
360#[cfg(windows)]
361impl Drop for StdHandlesPrivate {
362 fn drop(&mut self) {
363 for handle in &self.handles {
364 unsafe { SetHandleInformation(*handle, Self::INHERIT, Self::INHERIT) };
365 }
366 }
367}
368
369/// The computer's memory, in MiB.
370pub fn total_memory_mib() -> Option<u64> {
371 #[cfg(windows)]
372 {
373 #[repr(C)]
374 struct MemoryStatusEx {
375 length: u32,
376 load: u32,
377 total_phys: u64,
378 avail_phys: u64,
379 total_page: u64,
380 avail_page: u64,
381 total_virtual: u64,
382 avail_virtual: u64,
383 avail_extended: u64,
384 }
385 unsafe extern "system" {
386 fn GlobalMemoryStatusEx(buffer: *mut MemoryStatusEx) -> i32;
387 }
388 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 };
389 let ok = unsafe { GlobalMemoryStatusEx(&mut status) } != 0;
390 ok.then_some(status.total_phys >> 20)
391 }
392 #[cfg(target_os = "macos")]
393 {
394 let out = Command::new("sysctl").args(["-n", "hw.memsize"]).output().ok()?;
395 String::from_utf8_lossy(&out.stdout).trim().parse::<u64>().ok().map(|b| b >> 20)
396 }
397 #[cfg(all(unix, not(target_os = "macos")))]
398 {
399 let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
400 meminfo.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse::<u64>().ok().map(|kb| kb >> 10)
401 }
402}
403
404/// The sandbox's share when none is given: half the CPUs (at most 8) and
405/// a quarter of the memory (2 to 8 GiB, never more than half), and a disk
406/// that may grow to 64 GiB.
407pub fn default_resources(cpus: usize, memory_mib: Option<u64>) -> Resources {
408 let cpus = (cpus / 2).clamp(1, 8) as u32;
409 let total = memory_mib.unwrap_or(8192);
410 let memory_mib = (total / 4).clamp(2048, 8192).min(total / 2).max(1024);
411 Resources { cpus, memory_mib, disk_gib: 64 }
412}
413
414fn parse_gib(text: &str) -> Option<u64> {
415 super::exec::mib(text).map(|m| m.div_ceil(1024))
416}
417
418/// The backend for this computer's Linux runner.
419pub fn backend(dir: &Path) -> Box<dyn Backend> {
420 let dir = dir.to_path_buf();
421 match std::env::consts::OS {
422 "windows" => Box::new(wsl::Wsl { distro: wsl::DISTRO.to_owned(), dir }),
423 "macos" => Box::new(vm::DiskVm { hypervisor: vm::Hypervisor::Vfkit, dir }),
424 _ => {
425 let sysbox = sysbox::SysboxHost { dir: dir.clone() };
426 if sysbox.exists() || sysbox.availability().available {
427 Box::new(sysbox)
428 } else {
429 Box::new(vm::DiskVm { hypervisor: vm::Hypervisor::Qemu, dir })
430 }
431 }
432 }
433}
434
435// --- The native capability ------------------------------------------------------
436
437/// What this OS's native capability is called: `windows`, `macos`, or none on Linux.
438fn native_id() -> Option<&'static str> {
439 match std::env::consts::OS {
440 "windows" => Some("windows"),
441 "macos" => Some("macos"),
442 _ => None,
443 }
444}
445
446/// What this OS's native sandbox is called, and whether it is here.
447fn native_capability() -> Option<(&'static str, Availability)> {
448 match native_id()? {
449 "windows" => Some(("windows", WsbFound::look().availability())),
450 os => Some((os, MacVm::availability())),
451 }
452}
453
454/// The capabilities this computer has, by id: `linux`, and its OS's own.
455fn kinds() -> Vec<&'static str> {
456 let mut kinds = vec!["linux"];
457 kinds.extend(native_id());
458 kinds
459}
460
461/// How a capability is said to a person: `Windows`.
462fn kind_name(id: &str) -> &str {
463 match id {
464 "linux" => "Linux",
465 "windows" => "Windows",
466 "macos" => "macOS",
467 other => other,
468 }
469}
470
471/// `--only linux,windows`: the capabilities a command is for, of those
472/// this computer has (`native` is its OS's own), or None for all of them.
473fn only(options: &Options) -> Result<Option<Vec<&'static str>>> {
474 let Some(given) = options.value("only") else { return Ok(None) };
475 only_of(given, &kinds()).map(Some)
476}
477
478fn only_of(given: &str, have: &[&'static str]) -> Result<Vec<&'static str>> {
479 let mut chosen: Vec<&'static str> = Vec::new();
480 for word in given.split(',').map(str::trim).filter(|w| !w.is_empty()) {
481 let word = if word == "native" { have.iter().copied().find(|k| *k != "linux").unwrap_or(word) } else { word };
482 let Some(kind) = have.iter().find(|k| **k == word) else {
483 bail!("--only takes {} on this computer, not {word}", have.join(" or "));
484 };
485 if !chosen.contains(kind) {
486 chosen.push(kind);
487 }
488 }
489 if chosen.is_empty() {
490 bail!("--only takes {} on this computer", have.join(" or "));
491 }
492 Ok(chosen)
493}
494
495fn native_dir(dir: &Path) -> PathBuf {
496 dir.join("native")
497}
498
499fn native_pid(dir: &Path) -> Option<u32> {
500 std::fs::read_to_string(native_dir(dir).join("runner.pid")).ok().and_then(|p| p.trim().parse().ok())
501}
502
503fn native_running(dir: &Path) -> bool {
504 native_pid(dir).is_some_and(process_alive)
505}
506
507/// Registers (once) and starts the runner for native jobs on the computer.
508fn native_up(dir: &Path, saved: &Saved, token: Option<&str>, os: &str) -> Result<()> {
509 let folder = native_dir(dir);
510 if config::load(&folder).is_err() {
511 let (Some(url), Some(token)) = (saved.url.as_deref(), token) else {
512 bail!("the {os} runner is not registered: pass --url and --token");
513 };
514 let mut args = vec!["--url".to_owned(), url.to_owned(), "--token".into(), token.to_owned(), "--native".into()];
515 args.extend(["--name".into(), format!("{}-{os}", base_name(saved))]);
516 if !saved.labels.is_empty() {
517 args.extend(["--labels".into(), saved.labels.join(",")]);
518 }
519 if let Some(group) = &saved.group {
520 args.extend(["--group".into(), group.clone()]);
521 }
522 let options = Options::parse(&args, &super::VALUE_OPTIONS)?;
523 super::register(&options, &folder)?;
524 }
525 if native_running(dir) {
526 return Ok(());
527 }
528 let me = std::env::current_exe()?;
529 let log_file = std::fs::File::create(folder.join("runner.log"))?;
530 let mut command = Command::new(me);
531 command.args(["run", "--dir"]).arg(&folder).stdin(Stdio::null()).stdout(log_file.try_clone()?).stderr(log_file);
532 let pid = spawn_detached(command)?;
533 std::fs::write(folder.join("runner.pid"), pid.to_string())?;
534 Ok(())
535}
536
537fn native_down(dir: &Path, mode: StopMode) {
538 let folder = native_dir(dir);
539 let Some(pid) = native_pid(dir).filter(|p| process_alive(*p)) else { return };
540 let _ = std::fs::write(folder.join(if mode == StopMode::Drain { "drain" } else { "stop" }), "");
541 let limit = (mode == StopMode::Now).then(|| Instant::now() + Duration::from_secs(90));
542 while process_alive(pid) {
543 if limit.is_some_and(|l| Instant::now() > l) {
544 #[cfg(windows)]
545 let _ = Command::new("taskkill").args(["/F", "/PID", &pid.to_string()]).stdout(Stdio::null()).stderr(Stdio::null()).status();
546 #[cfg(unix)]
547 unsafe {
548 libc::kill(pid as libc::pid_t, libc::SIGKILL);
549 }
550 break;
551 }
552 std::thread::sleep(Duration::from_millis(500));
553 }
554 let _ = std::fs::remove_file(folder.join("runner.pid"));
555}
556
557fn base_name(saved: &Saved) -> String {
558 saved.name.clone().unwrap_or_else(|| super::runner_name(&super::hostname()))
559}
560
561// --- Status -----------------------------------------------------------------------
562
563/// The Linux capability's state from what is known.
564pub fn linux_state(available: bool, exists: bool, running: bool, phase: Option<&str>, guest: Option<&GuestStatus>, error: Option<&str>) -> &'static str {
565 if let Some(phase) = phase {
566 return if phase == "updating" { "updating" } else { "starting" };
567 }
568 if !exists {
569 return if available && error.is_none() { "absent" } else if error.is_some() { "error" } else { "absent" };
570 }
571 if !running {
572 return if error.is_some() { "error" } else { "stopped" };
573 }
574 match guest.map(|g| (g.state.as_str(), g.runner.is_some())) {
575 // Running, once the runner has registered and says so.
576 Some(("running" | "stopping", true)) => "running",
577 Some(("error", _)) => "error",
578 Some(("stopped", _)) => "stopped",
579 _ => "starting",
580 }
581}
582
583pub fn status(dir: &Path) -> Status {
584 let mut saved = Saved::load(dir);
585 // A launcher that died mid-way leaves no phase behind.
586 if saved.phase.is_some() && !saved.phase_pid.is_some_and(process_alive) {
587 saved.phase = None;
588 }
589 let backend = backend(dir);
590 let available = backend.availability();
591 let exists = backend.exists();
592 let running = exists && backend.running();
593 let guest = if running { backend.guest_status() } else { None };
594 let runner = guest.as_ref().and_then(|g| g.runner.clone());
595 let mut error = saved.error.clone().or_else(|| guest.as_ref().and_then(|g| g.error.clone()));
596 if !available.available && error.is_none() {
597 error = available.cause.clone();
598 }
599 let linux_off = saved.is_off("linux");
600 let mut state = linux_state(available.available, exists, running, saved.phase.as_deref(), guest.as_ref(), error.as_deref());
601 // Turned off and not running is stopped by a person: nothing is wrong.
602 if linux_off && saved.phase.is_none() && !running {
603 state = "stopped";
604 }
605 if state == "running" || (linux_off && state == "stopped") {
606 error = None;
607 }
608 let linux = Capability {
609 id: "linux".into(),
610 available: available.available,
611 state: state.into(),
612 isolation: Some(runner.as_ref().map(|r| r.isolation.clone()).unwrap_or_else(|| "nested-container".into())),
613 runner_version: runner.as_ref().map(|r| r.version.clone()),
614 job: runner.as_ref().and_then(|r| r.job.clone()),
615 cause: available.cause.clone(),
616 fix: available.fix.clone(),
617 turned_off: linux_off,
618 };
619 let mut capabilities = vec![linux.clone()];
620 if let Some((os, found)) = native_capability() {
621 let folder = native_dir(dir);
622 let native_status: Option<RunnerStatus> = std::fs::read(super::status_path(&folder)).ok().and_then(|b| serde_json::from_slice(&b).ok());
623 let registered = config::load(&folder).is_ok();
624 let alive = native_running(dir);
625 let off = saved.is_off(os);
626 let failed = saved.native_error.clone().filter(|_| !alive && !off && found.available);
627 let state = native_state(registered, alive, off, failed.is_some());
628 let (cause, fix) = match failed {
629 Some(error) => (Some(error), None),
630 None => (found.cause.clone(), found.fix.clone()),
631 };
632 capabilities.push(Capability {
633 id: os.into(),
634 available: found.available,
635 state: state.into(),
636 isolation: Some("vm".into()),
637 runner_version: native_status.as_ref().filter(|_| alive).map(|s| s.version.clone()),
638 job: native_status.filter(|_| alive).and_then(|s| s.job),
639 cause,
640 fix,
641 turned_off: off,
642 });
643 }
644 Status {
645 schema: SCHEMA,
646 state: linux.state,
647 backend: Some(backend.name().to_owned()),
648 runner_version: linux.runner_version,
649 launcher_version: VERSION.to_owned(),
650 // What the running image says it is, else what was installed.
651 image_version: guest.as_ref().and_then(|g| g.image_version.clone()).or_else(|| saved.image_version.clone()),
652 isolation: linux.isolation,
653 job: linux.job,
654 error,
655 resources: saved.resources,
656 capabilities,
657 state_dir: dir.display().to_string(),
658 }
659}
660
661/// The native capability's state: running while its runner lives;
662/// stopped when turned off, or registered and not running; `error` when
663/// it didn't start; `absent` before it is registered.
664pub fn native_state(registered: bool, alive: bool, off: bool, failed: bool) -> &'static str {
665 if alive {
666 "running"
667 } else if off {
668 "stopped"
669 } else if failed {
670 "error"
671 } else if registered {
672 "stopped"
673 } else {
674 "absent"
675 }
676}
677
678fn print(status: &Status, json: bool) {
679 if json {
680 println!("{}", serde_json::to_string_pretty(status).unwrap_or_default());
681 return;
682 }
683 println!("Sandbox: {} ({})", status.state, status.backend.as_deref().unwrap_or("none"));
684 for capability in &status.capabilities {
685 let said = match (capability.turned_off, capability.available) {
686 (true, _) => "stopped (turned off; `up --only` turns it on)",
687 (false, true) => capability.state.as_str(),
688 (false, false) => "unavailable",
689 };
690 let mut line = format!(" {:8} {said}", capability.id);
691 if let Some(job) = &capability.job {
692 line.push_str(&format!(", running {} in {}", job.name, job.repo));
693 }
694 println!("{line}");
695 if !capability.turned_off && (!capability.available || capability.state == "error") {
696 if let Some(cause) = &capability.cause {
697 println!(" {cause}");
698 }
699 if let Some(fix) = &capability.fix {
700 println!(" {fix}");
701 }
702 }
703 }
704 if let Some(error) = &status.error {
705 println!("Error: {error}");
706 }
707}
708
709// --- Commands -----------------------------------------------------------------------
710
711const HELP: &str = "g1t-runner sandbox: self-hosted runners on your own computer, sandboxed.
712
713 g1t-runner sandbox up [--url https://g1t.sh --token <registration token>]
714 [--name NAME] [--labels a,b] [--group GROUP]
715 [--cpus N] [--memory 8g] [--disk 64g] [--only linux,windows]
716 [--no-native | --native] [--json]
717 g1t-runner sandbox down [--only linux,windows] [--drain] [--delete] [--json]
718 g1t-runner sandbox status [--json]
719 g1t-runner sandbox update [--json]
720
721Linux jobs run in a Linux VM of the runner's own (a WSL distribution on
722Windows, a vfkit VM on macOS; on Linux, Sysbox, or a QEMU VM), each in a
723fresh container inside it. Windows jobs run each in a Windows Sandbox of
724its own (Windows 11 Pro, Enterprise or Education). Nothing a job runs
725reaches this computer's files, programs or Docker.
726
727The first `up` needs --url and --token (make a token under Settings,
728Runners). `down` stops now, cutting a running job short; --drain waits
729for it. Each kind of job starts and stops on its own: `down --only
730windows` stops Windows jobs and keeps them off, leaving Linux jobs
731running, until `up --only windows` turns them on again. `update` drains,
732swaps in the newest signed image, and starts
733again, keeping the registration. Every command takes --state-dir DIR
734(default ~/.g1t-runner/sandbox).
735Docs: https://docs.g1t.sh/guides/self-hosted-runners/#sandbox-on-your-computer";
736
737const SANDBOX_VALUES: &[&str] = &[
738 "url",
739 "token",
740 "name",
741 "labels",
742 "group",
743 "cpus",
744 "memory",
745 "disk",
746 "state-dir",
747 "image-file",
748 "runner-image",
749 "runner-image-file",
750 "wait",
751 "only",
752];
753
754/// `g1t-runner sandbox <command>`: the process's exit code.
755pub fn main(args: &[String]) -> i32 {
756 if args.first().map(String::as_str) == Some("guest") {
757 return match guest::main(&args[1..]) {
758 Ok(()) => 0,
759 Err(error) => {
760 eprintln!("g1t-runner: {error:#}");
761 1
762 }
763 };
764 }
765 let options = match Options::parse(args.get(1..).unwrap_or_default(), SANDBOX_VALUES) {
766 Ok(options) => options,
767 Err(error) => {
768 eprintln!("g1t-runner: {error:#}");
769 return 2;
770 }
771 };
772 let json = options.flag("json");
773 let dir = options.value("state-dir").map(PathBuf::from).unwrap_or_else(|| config::folder(None).join("sandbox"));
774 let outcome = match args.first().map(String::as_str) {
775 Some("up") => up(&options, &dir),
776 Some("down") => down(&options, &dir),
777 Some("update") => update_sandbox(&options, &dir),
778 Some("status") => Ok(()),
779 _ => {
780 println!("{HELP}");
781 return 0;
782 }
783 };
784 let mut status = status(&dir);
785 match outcome {
786 Ok(()) => {
787 print(&status, json);
788 0
789 }
790 Err(error) => {
791 status.error = Some(format!("{error:#}"));
792 if json {
793 print(&status, true);
794 } else {
795 eprintln!("g1t-runner: {error:#}");
796 }
797 1
798 }
799 }
800}
801
802/// Runs `step` as a phase (`starting`, `updating`) that `status` reports,
803/// keeping what went wrong for it.
804fn in_phase(dir: &Path, phase: &str, step: impl FnOnce(&mut Saved) -> Result<()>) -> Result<()> {
805 let mut saved = Saved::load(dir);
806 saved.phase = Some(phase.to_owned());
807 saved.phase_pid = Some(std::process::id());
808 saved.error = None;
809 saved.save(dir)?;
810 let outcome = step(&mut saved);
811 saved.phase = None;
812 saved.phase_pid = None;
813 saved.error = outcome.as_ref().err().map(|e| format!("{e:#}"));
814 saved.save(dir)?;
815 outcome
816}
817
818fn up(options: &Options, dir: &Path) -> Result<()> {
819 let mut saved = Saved::load(dir);
820 if let Some(url) = options.value("url") {
821 saved.url = Some(url.trim_end_matches('/').to_owned());
822 }
823 if let Some(name) = options.value("name") {
824 saved.name = Some(super::runner_name(name));
825 }
826 if let Some(labels) = options.value("labels") {
827 saved.labels = labels.split(',').map(|l| l.trim().to_owned()).filter(|l| !l.is_empty()).collect();
828 }
829 if let Some(group) = options.value("group") {
830 saved.group = Some(group.to_owned());
831 }
832 let defaults = default_resources(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2), total_memory_mib());
833 let mut resources = if saved.resources == Resources::default() { defaults } else { saved.resources };
834 if let Some(cpus) = options.value("cpus") {
835 resources.cpus = cpus.parse().ok().filter(|n| *n > 0).ok_or_else(|| anyhow!("--cpus takes a number of CPUs, not {cpus}"))?;
836 }
837 if let Some(memory) = options.value("memory") {
838 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}"))?;
839 }
840 if let Some(disk) = options.value("disk") {
841 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}"))?;
842 }
843 saved.resources = resources;
844 // Each capability on or off, kept until said otherwise: `--only` turns
845 // on what it names and leaves the rest as they are.
846 let only = only(options)?;
847 if let Some(native) = native_id() {
848 if options.flag("no-native") {
849 saved.turn(native, false);
850 } else if options.flag("native") {
851 saved.turn(native, true);
852 }
853 }
854 for kind in only.iter().flatten() {
855 saved.turn(kind, true);
856 }
857 let wanted = |kind: &str| match &only {
858 Some(only) => only.contains(&kind),
859 None => !saved.is_off(kind),
860 };
861 let (want_linux, native) = (wanted("linux"), native_capability().filter(|(os, _)| wanted(os)));
862 if let Some(image) = options.value("runner-image") {
863 saved.runner_image = Some(image.to_owned());
864 }
865 saved.save(dir)?;
866 // From G1T_RUNNER_TOKEN too, as register-and-run takes it: a token on
867 // the command line shows in the process list to anyone on the machine
868 // while `up` runs, and the desktop app passes it this way.
869 let token = options
870 .value("token")
871 .map(str::to_owned)
872 .or_else(|| std::env::var("G1T_RUNNER_TOKEN").ok().filter(|token| !token.trim().is_empty()));
873
874 if !want_linux && native.is_none() {
875 log("Every kind of job is turned off here: `g1t-runner sandbox up --only linux` turns Linux jobs on again.");
876 return Ok(());
877 }
878 let backend = backend(dir);
879 let found = backend.availability();
880 let linux = if !want_linux {
881 None
882 } else if found.available {
883 Some(in_phase(dir, "starting", |saved| {
884 let fresh = !backend.exists();
885 if fresh {
886 let local = obtain_image(options, saved, backend.as_ref(), dir)?;
887 backend.create(&local, &saved.resources)?;
888 saved.image_version = Some(local.version.clone());
889 if local.runner_image.is_some() {
890 saved.runner_image = local.runner_image.clone();
891 }
892 // An update that stopped half-way left the registration here.
893 let carried = dir.join("carry").join("config.json");
894 if let Ok(bytes) = std::fs::read(&carried) {
895 backend.import_state(&bytes)?;
896 let _ = std::fs::remove_file(&carried);
897 }
898 }
899 if let Some(tar) = options.value("runner-image-file") {
900 backend.load_image(Path::new(tar))?;
901 }
902 let Some(runner_image) = saved.runner_image.clone() else {
903 bail!("there is no runner image for the sandbox: this build is not a release, so pass --runner-image or --runner-image-file");
904 };
905 backend.configure(&Settings {
906 url: saved.url.clone(),
907 token: token.clone(),
908 name: Some(format!("{}-linux", base_name(saved))),
909 labels: saved.labels.clone(),
910 group: saved.group.clone(),
911 cpus: saved.resources.cpus,
912 memory_mib: saved.resources.memory_mib,
913 runner_image,
914 image_version: saved.image_version.clone().unwrap_or_default(),
915 stop: None,
916 })?;
917 backend.start()?;
918 wait_running(backend.as_ref(), Duration::from_secs(if fresh { 600 } else { 180 }))
919 }))
920 } else {
921 Some(Err(anyhow!("{} {}", found.cause.unwrap_or_default(), found.fix.unwrap_or_default())))
922 };
923 let native = match native {
924 Some((os, found)) if found.available => {
925 let started = native_up(dir, &Saved::load(dir), token.as_deref(), os);
926 // Why it didn't start is the capability's, for `status`, until it does.
927 let mut kept = Saved::load(dir);
928 kept.native_error = started.as_ref().err().map(|e| format!("{e:#}"));
929 kept.save(dir)?;
930 Some((os, started))
931 }
932 // Asked for by name, and not here: why, rather than nothing.
933 Some((os, found)) if only.is_some() => Some((os, Err(anyhow!("{} {}", found.cause.unwrap_or_default(), found.fix.unwrap_or_default()).context(format!("{} jobs can't run here", kind_name(os)))))),
934 _ => None,
935 };
936 match (linux, native) {
937 (None | Some(Ok(())), None | Some((_, Ok(())))) => Ok(()),
938 (Some(Err(linux)), Some((os, Ok(())))) => Err(linux.context(format!("{} jobs are sandboxed and running, but Linux jobs are not", kind_name(os)))),
939 (Some(Err(linux)), _) => Err(linux),
940 (Some(Ok(())), Some((os, Err(native)))) => Err(native.context(format!("Linux jobs are sandboxed and running, but {} jobs are not", kind_name(os)))),
941 (None, Some((_, Err(native)))) => Err(native),
942 }
943}
944
945/// Waits until the sandbox's runner is registered and asking for work (it
946/// has written its status), or says what went wrong.
947fn wait_running(backend: &dyn Backend, limit: Duration) -> Result<()> {
948 let until = Instant::now() + limit;
949 loop {
950 if let Some(status) = backend.guest_status() {
951 match status.state.as_str() {
952 "running" if status.runner.as_ref().is_some_and(|r| r.state != "stopped") => return Ok(()),
953 "error" => bail!("{}", status.error.unwrap_or_else(|| "the sandbox stopped".into())),
954 _ => {}
955 }
956 }
957 if Instant::now() > until {
958 bail!("the sandbox did not start within {} seconds; `g1t-runner sandbox status` says how it is now", limit.as_secs());
959 }
960 std::thread::sleep(Duration::from_secs(1));
961 }
962}
963
964fn obtain_image(options: &Options, saved: &Saved, backend: &dyn Backend, dir: &Path) -> Result<image::Local> {
965 let keys = backend.image_keys();
966 if let Some(file) = options.value("image-file") {
967 log(&format!("Using {file}, which is not a release: it is not checked against g1t's signature."));
968 return image::local(&keys, Path::new(file), saved.runner_image.clone());
969 }
970 if keys.is_empty() {
971 // Sysbox on Linux: only the runner's image, named in the release.
972 let manifest = image::latest(saved.site());
973 return Ok(image::Local {
974 version: manifest.as_ref().map(|m| m.version.clone()).unwrap_or_else(|_| VERSION.to_owned()),
975 files: Default::default(),
976 runner_image: manifest.ok().and_then(|m| m.sandbox).and_then(|s| s.runner_image).or_else(|| saved.runner_image.clone()),
977 });
978 }
979 let manifest = image::latest(saved.site())?;
980 image::obtain(saved.site(), &manifest, &keys, &dir.join("images"), &|line| log(line))
981}
982
983fn down(options: &Options, dir: &Path) -> Result<()> {
984 let mode = if options.flag("drain") { StopMode::Drain } else { StopMode::Now };
985 if let Some(only) = only(options)? {
986 return down_only(dir, mode, &only, options.flag("delete"));
987 }
988 native_down(dir, mode);
989 let backend = backend(dir);
990 if options.flag("delete") {
991 backend.remove()?;
992 let _ = std::fs::remove_dir_all(dir.join("images"));
993 let _ = std::fs::remove_dir_all(native_dir(dir));
994 let _ = std::fs::remove_dir_all(dir.join("carry"));
995 let mut saved = Saved::load(dir);
996 saved.image_version = None;
997 saved.error = None;
998 saved.save(dir)?;
999 log("Deleted the sandbox. Its runners are still listed on g1t until you remove them there.");
1000 return Ok(());
1001 }
1002 if backend.exists() {
1003 backend.stop(mode)?;
1004 }
1005 let mut saved = Saved::load(dir);
1006 saved.error = None;
1007 saved.native_error = None;
1008 saved.save(dir)
1009}
1010
1011/// `down --only`: stops those capabilities and turns them off, leaving the
1012/// others as they are, running or not.
1013fn down_only(dir: &Path, mode: StopMode, only: &[&'static str], delete: bool) -> Result<()> {
1014 if delete {
1015 bail!("--delete removes the whole sandbox, so it takes no --only");
1016 }
1017 for kind in only {
1018 if *kind == "linux" {
1019 let backend = backend(dir);
1020 if backend.exists() && backend.running() {
1021 backend.stop(mode)?;
1022 }
1023 } else {
1024 native_down(dir, mode);
1025 }
1026 let mut saved = Saved::load(dir);
1027 if *kind == "linux" {
1028 saved.error = None;
1029 } else {
1030 saved.native_error = None;
1031 }
1032 saved.turn(kind, false);
1033 saved.save(dir)?;
1034 log(&format!("Stopped {} jobs; they stay off until `g1t-runner sandbox up --only {kind}`.", kind_name(kind)));
1035 }
1036 Ok(())
1037}
1038
1039fn update_sandbox(options: &Options, dir: &Path) -> Result<()> {
1040 let backend = backend(dir);
1041 if !backend.exists() {
1042 bail!("there is no sandbox to update: run `g1t-runner sandbox up`");
1043 }
1044 let saved = Saved::load(dir);
1045 let local = if options.value("image-file").is_some() {
1046 obtain_image(options, &saved, backend.as_ref(), dir)?
1047 } else {
1048 let manifest = image::latest(saved.site())?;
1049 if saved.image_version.as_deref().is_some_and(|current| !super::update::newer(&manifest.version, current)) {
1050 log(&format!("The sandbox's image is the newest, {}.", manifest.version));
1051 return Ok(());
1052 }
1053 let keys = backend.image_keys();
1054 if keys.is_empty() {
1055 obtain_image(options, &saved, backend.as_ref(), dir)?
1056 } else {
1057 image::obtain(saved.site(), &manifest, &keys, &dir.join("images"), &|line| log(line))?
1058 }
1059 };
1060 in_phase(dir, "updating", |saved| {
1061 let was_running = backend.running();
1062 if was_running {
1063 log("Draining: the runner finishes the job it is running, then stops.");
1064 backend.stop(StopMode::Drain)?;
1065 }
1066 // The registration, kept on the computer until the new sandbox has it.
1067 let carried = dir.join("carry").join("config.json");
1068 if let Some(bytes) = backend.export_state()? {
1069 std::fs::create_dir_all(dir.join("carry"))?;
1070 write_private(&carried, &bytes)?;
1071 }
1072 backend.remove_system()?;
1073 backend.create(&local, &saved.resources)?;
1074 if let Ok(bytes) = std::fs::read(&carried) {
1075 backend.import_state(&bytes)?;
1076 let _ = std::fs::remove_file(&carried);
1077 }
1078 if let Some(tar) = options.value("runner-image-file") {
1079 backend.load_image(Path::new(tar))?;
1080 }
1081 saved.image_version = Some(local.version.clone());
1082 if local.runner_image.is_some() {
1083 saved.runner_image = local.runner_image.clone();
1084 }
1085 backend.configure(&Settings {
1086 runner_image: saved.runner_image.clone().unwrap_or_default(),
1087 image_version: local.version.clone(),
1088 cpus: saved.resources.cpus,
1089 memory_mib: saved.resources.memory_mib,
1090 url: saved.url.clone(),
1091 name: Some(format!("{}-linux", base_name(saved))),
1092 labels: saved.labels.clone(),
1093 group: saved.group.clone(),
1094 ..Settings::default()
1095 })?;
1096 if was_running {
1097 backend.start()?;
1098 wait_running(backend.as_ref(), Duration::from_secs(600))?;
1099 }
1100 image::prune(&dir.join("images"), &local.version);
1101 if local.version == "local" {
1102 log("The sandbox is on the image from --image-file.");
1103 } else {
1104 log(&format!("The sandbox is on image {}.", local.version));
1105 }
1106 Ok(())
1107 })
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112 use super::*;
1113
1114 #[test]
1115 fn the_default_share_leaves_the_computer_room() {
1116 assert_eq!(default_resources(16, Some(65536)), Resources { cpus: 8, memory_mib: 8192, disk_gib: 64 });
1117 assert_eq!(default_resources(8, Some(16384)), Resources { cpus: 4, memory_mib: 4096, disk_gib: 64 });
1118 assert_eq!(default_resources(4, Some(8192)), Resources { cpus: 2, memory_mib: 2048, disk_gib: 64 });
1119 // Never more than half a small machine's memory.
1120 assert_eq!(default_resources(2, Some(3072)).memory_mib, 1536);
1121 assert_eq!(default_resources(1, None).cpus, 1);
1122 assert_eq!(parse_gib("64g"), Some(64));
1123 assert_eq!(parse_gib("100000m"), Some(98));
1124 }
1125
1126 #[test]
1127 fn the_state_says_what_a_person_needs_to_know() {
1128 let running = GuestStatus { state: "running".into(), runner: Some(RunnerStatus { state: "idle".into(), ..RunnerStatus::default() }), ..GuestStatus::default() };
1129 let unregistered = GuestStatus { state: "running".into(), ..GuestStatus::default() };
1130 let failed = GuestStatus { state: "error".into(), error: Some("x".into()), ..GuestStatus::default() };
1131 assert_eq!(linux_state(true, false, false, None, None, None), "absent");
1132 assert_eq!(linux_state(false, false, false, None, None, Some("WSL is not installed.")), "error");
1133 assert_eq!(linux_state(true, true, false, None, None, None), "stopped");
1134 assert_eq!(linux_state(true, true, true, None, None, None), "starting");
1135 assert_eq!(linux_state(true, true, true, None, Some(&running), None), "running");
1136 assert_eq!(linux_state(true, true, true, None, Some(&unregistered), None), "starting");
1137 assert_eq!(linux_state(true, true, true, None, Some(&failed), None), "error");
1138 assert_eq!(linux_state(true, true, true, Some("updating"), Some(&running), None), "updating");
1139 assert_eq!(linux_state(true, false, false, Some("starting"), None, None), "starting");
1140 }
1141
1142 #[test]
1143 fn the_status_contract_holds_its_fields() {
1144 let status = Status {
1145 schema: SCHEMA,
1146 state: "running".into(),
1147 backend: Some("wsl2".into()),
1148 launcher_version: VERSION.into(),
1149 capabilities: vec![Capability { id: "linux".into(), available: true, state: "running".into(), ..Capability::default() }],
1150 ..Status::default()
1151 };
1152 let value = serde_json::to_value(&status).unwrap();
1153 for field in ["schema", "state", "backend", "runner_version", "launcher_version", "image_version", "isolation", "job", "error", "resources", "capabilities", "state_dir"] {
1154 assert!(value.get(field).is_some(), "{field}");
1155 }
1156 for field in ["id", "available", "state", "isolation", "runner_version", "job", "cause", "fix", "turned_off"] {
1157 assert!(value["capabilities"][0].get(field).is_some(), "{field}");
1158 }
1159 assert_eq!(value["resources"]["memory_mib"], 0);
1160 }
1161
1162 #[test]
1163 fn a_status_with_nothing_made_is_absent_and_says_where() {
1164 let dir = std::env::temp_dir().join(format!("g1t-sandbox-status-{}", std::process::id()));
1165 let saved = Saved { phase: Some("updating".into()), phase_pid: Some(u32::MAX - 1), ..Saved::default() };
1166 saved.save(&dir).unwrap();
1167 // A launcher that died mid-update leaves no phase in the status.
1168 assert_ne!(status(&dir).state, "updating");
1169 assert_eq!(status(&dir).state_dir, dir.display().to_string());
1170 let _ = std::fs::remove_dir_all(&dir);
1171 }
1172
1173 #[test]
1174 fn only_names_what_this_computer_has() {
1175 let windows = ["linux", "windows"];
1176 assert_eq!(only_of("windows", &windows).unwrap(), ["windows"]);
1177 assert_eq!(only_of("linux, windows,linux", &windows).unwrap(), ["linux", "windows"]);
1178 assert_eq!(only_of("native", &windows).unwrap(), ["windows"]);
1179 assert!(only_of("macos", &windows).unwrap_err().to_string().contains("linux or windows"));
1180 assert!(only_of(",", &windows).is_err());
1181 // Linux has no native capability: `native` is not one of its kinds.
1182 assert!(only_of("native", &["linux"]).unwrap_err().to_string().contains("not native"));
1183 }
1184
1185 #[test]
1186 fn a_capability_turned_off_stays_off_and_reads_as_stopped() {
1187 let mut saved = Saved::default();
1188 saved.turn("windows", false);
1189 saved.turn("windows", false);
1190 assert_eq!(saved.off, ["windows"]);
1191 assert!(saved.is_off("windows") && !saved.is_off("linux"));
1192 saved.turn("windows", true);
1193 assert!(saved.off.is_empty());
1194 // Stopped by a person is not failing, and running is running whatever it says.
1195 assert_eq!(native_state(true, false, true, true), "stopped");
1196 assert_eq!(native_state(true, true, true, false), "running");
1197 assert_eq!(native_state(true, false, false, true), "error");
1198 assert_eq!(native_state(true, false, false, false), "stopped");
1199 assert_eq!(native_state(false, false, false, false), "absent");
1200 }
1201
1202 #[test]
1203 fn an_earlier_no_native_is_read_as_turned_off() {
1204 let dir = std::env::temp_dir().join(format!("g1t-sandbox-off-{}", std::process::id()));
1205 std::fs::create_dir_all(&dir).unwrap();
1206 std::fs::write(dir.join("sandbox.json"), r#"{"no_native":true,"labels":[]}"#).unwrap();
1207 let saved = Saved::load(&dir);
1208 assert!(!saved.no_native);
1209 assert_eq!(saved.off, native_id().into_iter().collect::<Vec<_>>());
1210 saved.save(&dir).unwrap();
1211 let written = std::fs::read_to_string(dir.join("sandbox.json")).unwrap();
1212 assert!(!written.contains("no_native"));
1213 let _ = std::fs::remove_dir_all(&dir);
1214 }
1215
1216 #[test]
1217 fn this_process_is_alive() {
1218 assert!(process_alive(std::process::id()));
1219 assert!(!process_alive(u32::MAX - 1));
1220 assert!(total_memory_mib().unwrap_or(1) > 0);
1221 }
1222}