Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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