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,377 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 folders: runner/host. On Windows it keeps its
730 // runner in local data (%LOCALAPPDATA%), and in roaming data
731 // (%APPDATA%) on a computer that set it up there before; elsewhere the
732 // two are one folder.
733 let data: Vec<PathBuf> = match std::env::consts::OS {
734 "windows" => ["LOCALAPPDATA", "APPDATA"].iter().filter_map(|var| std::env::var_os(var)).map(PathBuf::from).collect(),
735 "macos" => home.iter().map(|h| h.join("Library").join("Application Support")).collect(),
736 _ => 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"))).into_iter().collect(),
737 };
738 for data in data {
739 folders.push(data.join("sh.g1t.desktop").join("runner").join("host"));
740 }
741 folders.dedup();
742 folders
743}
744
745/// Those of `folders` that hold a runner registered for host mode: jobs as
746/// processes of this account, neither in a container nor in a VM.
747fn host_mode_runners(folders: &[PathBuf]) -> Vec<PathBuf> {
748 folders.iter().filter(|folder| config::load(folder).is_ok_and(|c| !c.docker && !c.native)).cloned().collect()
749}
750
751fn host_mode_warning(folder: &Path) -> String {
752 format!(
753 "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.",
754 folder.display(),
755 folder.display()
756 )
757}
758
759/// The native capability's state: running while its runner lives;
760/// stopped when turned off, or registered and not running; `error` when
761/// it didn't start; `absent` before it is registered.
762pub fn native_state(registered: bool, alive: bool, off: bool, failed: bool) -> &'static str {
763 if alive {
764 "running"
765 } else if off {
766 "stopped"
767 } else if failed {
768 "error"
769 } else if registered {
770 "stopped"
771 } else {
772 "absent"
773 }
774}
775
776fn print(status: &Status, json: bool) {
777 if json {
778 println!("{}", serde_json::to_string_pretty(status).unwrap_or_default());
779 return;
780 }
781 println!("Sandbox: {} ({})", status.state, status.backend.as_deref().unwrap_or("none"));
782 for capability in &status.capabilities {
783 let said = match (capability.turned_off, capability.available) {
784 (true, _) => "stopped (turned off; `up --only` turns it on)",
785 (false, true) => capability.state.as_str(),
786 (false, false) => "unavailable",
787 };
788 let mut line = format!(" {:8} {said}", capability.id);
789 if let Some(job) = &capability.job {
790 line.push_str(&format!(", running {} in {}", job.name, job.repo));
791 }
792 println!("{line}");
793 if !capability.turned_off && (!capability.available || capability.state == "error") {
794 if let Some(cause) = &capability.cause {
795 println!(" {cause}");
796 }
797 if let Some(fix) = &capability.fix {
798 println!(" {fix}");
799 }
800 }
801 }
802 for warning in &status.warnings {
803 println!("Warning: {warning}");
804 }
805 if let Some(error) = &status.error {
806 println!("Error: {error}");
807 }
808}
809
810// --- Commands -----------------------------------------------------------------------
811
812const HELP: &str = "g1t-runner sandbox: self-hosted runners on your own computer, sandboxed.
813
814 g1t-runner sandbox up [--url https://g1t.sh --token <registration token>]
815 [--name NAME] [--labels a,b] [--group GROUP]
816 [--cpus N] [--memory 8g] [--disk 64g] [--only linux,windows]
817 [--no-native | --native] [--json]
818 g1t-runner sandbox down [--only linux,windows] [--drain] [--delete] [--json]
819 g1t-runner sandbox status [--json]
820 g1t-runner sandbox update [--json]
821
822Linux jobs run in a Linux VM of the runner's own (a WSL distribution on
823Windows, a vfkit VM on macOS; on Linux, Sysbox, or a QEMU VM), each in a
824fresh container inside it. Windows jobs run each in a Windows Sandbox of
825its own (Windows 11 Pro, Enterprise or Education). Nothing a job runs
826reaches this computer's files, programs or Docker.
827
828The first `up` needs --url and --token (make a token under Settings,
829Runners). `down` stops now, cutting a running job short; --drain waits
830for it. Each kind of job starts and stops on its own: `down --only
831windows` stops Windows jobs and keeps them off, leaving Linux jobs
832running, until `up --only windows` turns them on again. `update` drains,
833swaps in the newest signed image, and starts
834again, keeping the registration. Every command takes --state-dir DIR
835(default ~/.g1t-runner/sandbox).
836Docs: https://docs.g1t.sh/guides/self-hosted-runners/#sandbox-on-your-computer";
837
838const SANDBOX_VALUES: &[&str] = &[
839 "url",
840 "token",
841 "name",
842 "labels",
843 "group",
844 "cpus",
845 "memory",
846 "disk",
847 "state-dir",
848 "image-file",
849 "runner-image",
850 "runner-image-file",
851 "wait",
852 "only",
853];
854
855/// `g1t-runner sandbox <command>`: the process's exit code.
856pub fn main(args: &[String]) -> i32 {
857 if args.first().map(String::as_str) == Some("guest") {
858 return match guest::main(&args[1..]) {
859 Ok(()) => 0,
860 Err(error) => {
861 eprintln!("g1t-runner: {error:#}");
862 1
863 }
864 };
865 }
866 let options = match Options::parse(args.get(1..).unwrap_or_default(), SANDBOX_VALUES) {
867 Ok(options) => options,
868 Err(error) => {
869 eprintln!("g1t-runner: {error:#}");
870 return 2;
871 }
872 };
873 let json = options.flag("json");
874 let dir = options.value("state-dir").map(PathBuf::from).unwrap_or_else(|| config::folder(None).join("sandbox"));
875 // The state folder holds the runners' credentials and the VM's disk:
876 // only this account's, or nothing is made or started in it.
877 let private = || private::folder(&dir, "--state-dir");
878 let outcome = match args.first().map(String::as_str) {
879 Some("up") => private().and_then(|()| up(&options, &dir)),
880 Some("down") => private().and_then(|()| down(&options, &dir)),
881 Some("update") => private().and_then(|()| update_sandbox(&options, &dir)),
882 Some("status") => Ok(()),
883 _ => {
884 println!("{HELP}");
885 return 0;
886 }
887 };
888 let mut status = status(&dir);
889 match outcome {
890 Ok(()) => {
891 print(&status, json);
892 0
893 }
894 Err(error) => {
895 status.error = Some(format!("{error:#}"));
896 if json {
897 print(&status, true);
898 } else {
899 eprintln!("g1t-runner: {error:#}");
900 }
901 1
902 }
903 }
904}
905
906/// Runs `step` as a phase (`starting`, `updating`) that `status` reports,
907/// keeping what went wrong for it.
908fn in_phase(dir: &Path, phase: &str, step: impl FnOnce(&mut Saved) -> Result<()>) -> Result<()> {
909 let mut saved = Saved::load(dir);
910 saved.phase = Some(phase.to_owned());
911 saved.phase_pid = Some(std::process::id());
912 saved.error = None;
913 saved.save(dir)?;
914 let outcome = step(&mut saved);
915 saved.phase = None;
916 saved.phase_pid = None;
917 saved.error = outcome.as_ref().err().map(|e| format!("{e:#}"));
918 saved.save(dir)?;
919 outcome
920}
921
922fn up(options: &Options, dir: &Path) -> Result<()> {
923 let mut saved = Saved::load(dir);
924 if let Some(url) = options.value("url") {
925 saved.url = Some(url.trim_end_matches('/').to_owned());
926 }
927 if let Some(name) = options.value("name") {
928 saved.name = Some(super::runner_name(name));
929 }
930 if let Some(labels) = options.value("labels") {
931 saved.labels = labels.split(',').map(|l| l.trim().to_owned()).filter(|l| !l.is_empty()).collect();
932 }
933 if let Some(group) = options.value("group") {
934 saved.group = Some(group.to_owned());
935 }
936 let defaults = default_resources(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2), total_memory_mib());
937 let mut resources = if saved.resources == Resources::default() { defaults } else { saved.resources };
938 if let Some(cpus) = options.value("cpus") {
939 resources.cpus = cpus.parse().ok().filter(|n| *n > 0).ok_or_else(|| anyhow!("--cpus takes a number of CPUs, not {cpus}"))?;
940 }
941 if let Some(memory) = options.value("memory") {
942 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}"))?;
943 }
944 if let Some(disk) = options.value("disk") {
945 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}"))?;
946 }
947 saved.resources = resources;
948 // Each capability on or off, kept until said otherwise: `--only` turns
949 // on what it names and leaves the rest as they are.
950 let only = only(options)?;
951 if let Some(native) = native_id() {
952 if options.flag("no-native") {
953 saved.turn(native, false);
954 } else if options.flag("native") {
955 saved.turn(native, true);
956 }
957 }
958 for kind in only.iter().flatten() {
959 saved.turn(kind, true);
960 }
961 let wanted = |kind: &str| match &only {
962 Some(only) => only.contains(&kind),
963 None => !saved.is_off(kind),
964 };
965 let (want_linux, native) = (wanted("linux"), native_capability(dir).filter(|(os, _)| wanted(os)));
966 if let Some(image) = options.value("runner-image") {
967 saved.runner_image = Some(image.to_owned());
968 }
969 saved.save(dir)?;
970 // From G1T_RUNNER_TOKEN too, as register-and-run takes it: a token on
971 // the command line shows in the process list to anyone on the machine
972 // while `up` runs, and the desktop app passes it this way.
973 let token = options
974 .value("token")
975 .map(str::to_owned)
976 .or_else(|| std::env::var("G1T_RUNNER_TOKEN").ok().filter(|token| !token.trim().is_empty()));
977
978 if !want_linux && native.is_none() {
979 log("Every kind of job is turned off here: `g1t-runner sandbox up --only linux` turns Linux jobs on again.");
980 return Ok(());
981 }
982 let backend = backend(dir);
983 let found = backend.availability();
984 let linux = if !want_linux {
985 None
986 } else if found.available {
987 Some(in_phase(dir, "starting", |saved| {
988 let fresh = !backend.exists();
989 if fresh {
990 let local = obtain_image(options, saved, backend.as_ref(), dir)?;
991 backend.create(&local, &saved.resources)?;
992 saved.image_version = Some(local.version.clone());
993 if local.runner_image.is_some() {
994 saved.runner_image = local.runner_image.clone();
995 }
996 }
997 // An update that stopped half-way, after the old system was
998 // removed, left the registration here.
999 let carried = dir.join("carry").join("config.json");
1000 if let Ok(bytes) = std::fs::read(&carried) {
1001 backend.import_state(&bytes)?;
1002 let _ = std::fs::remove_dir_all(dir.join("carry"));
1003 }
1004 if let Some(tar) = options.value("runner-image-file") {
1005 backend.load_image(Path::new(tar))?;
1006 }
1007 let Some(runner_image) = saved.runner_image.clone() else {
1008 bail!("there is no runner image for the sandbox: this build is not a release, so pass --runner-image or --runner-image-file");
1009 };
1010 backend.configure(&Settings {
1011 url: saved.url.clone(),
1012 token: token.clone(),
1013 name: Some(format!("{}-linux", base_name(saved))),
1014 labels: saved.labels.clone(),
1015 group: saved.group.clone(),
1016 machine: super::machine_fingerprint(),
1017 cpus: saved.resources.cpus,
1018 memory_mib: saved.resources.memory_mib,
1019 runner_image,
1020 image_version: saved.image_version.clone().unwrap_or_default(),
1021 stop: None,
1022 })?;
1023 backend.start()?;
1024 wait_running(backend.as_ref(), Duration::from_secs(if fresh { 600 } else { 180 }))
1025 }))
1026 } else {
1027 Some(Err(anyhow!("{} {}", found.cause.unwrap_or_default(), found.fix.unwrap_or_default())))
1028 };
1029 let native = match native {
1030 Some((os, found)) if found.available => {
1031 let started = native_up(dir, &Saved::load(dir), token.as_deref(), os);
1032 // Why it didn't start is the capability's, for `status`, until it does.
1033 let mut kept = Saved::load(dir);
1034 kept.native_error = started.as_ref().err().map(|e| format!("{e:#}"));
1035 kept.save(dir)?;
1036 Some((os, started))
1037 }
1038 // Asked for by name, and not here: why, rather than nothing.
1039 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)))))),
1040 _ => None,
1041 };
1042 match (linux, native) {
1043 (None | Some(Ok(())), None | Some((_, Ok(())))) => Ok(()),
1044 (Some(Err(linux)), Some((os, Ok(())))) => Err(linux.context(format!("{} jobs are sandboxed and running, but Linux jobs are not", kind_name(os)))),
1045 (Some(Err(linux)), _) => Err(linux),
1046 (Some(Ok(())), Some((os, Err(native)))) => Err(native.context(format!("Linux jobs are sandboxed and running, but {} jobs are not", kind_name(os)))),
1047 (None, Some((_, Err(native)))) => Err(native),
1048 }
1049}
1050
1051/// Waits until the sandbox's runner is registered and asking for work (it
1052/// has written its status), or says what went wrong.
1053fn wait_running(backend: &dyn Backend, limit: Duration) -> Result<()> {
1054 let until = Instant::now() + limit;
1055 loop {
1056 if let Some(status) = backend.guest_status() {
1057 match status.state.as_str() {
1058 "running" if status.runner.as_ref().is_some_and(|r| r.state != "stopped") => return Ok(()),
1059 "error" => bail!("{}", status.error.unwrap_or_else(|| "the sandbox stopped".into())),
1060 _ => {}
1061 }
1062 }
1063 if Instant::now() > until {
1064 bail!("the sandbox did not start within {} seconds; `g1t-runner sandbox status` says how it is now", limit.as_secs());
1065 }
1066 std::thread::sleep(Duration::from_secs(1));
1067 }
1068}
1069
1070fn obtain_image(options: &Options, saved: &Saved, backend: &dyn Backend, dir: &Path) -> Result<image::Local> {
1071 let keys = backend.image_keys();
1072 if let Some(file) = options.value("image-file") {
1073 log(&format!("Using {file}, which is not a release: it is not checked against g1t's signature."));
1074 return image::local(&keys, Path::new(file), saved.runner_image.clone());
1075 }
1076 if keys.is_empty() {
1077 // Sysbox on Linux: only the runner's image, named in the release.
1078 let manifest = image::latest(saved.site());
1079 return Ok(image::Local {
1080 version: manifest.as_ref().map(|m| m.version.clone()).unwrap_or_else(|_| VERSION.to_owned()),
1081 files: Default::default(),
1082 runner_image: manifest.ok().and_then(|m| m.sandbox).and_then(|s| s.runner_image).or_else(|| saved.runner_image.clone()),
1083 });
1084 }
1085 let manifest = image::latest(saved.site())?;
1086 image::obtain(saved.site(), &manifest, &keys, &dir.join("images"), &|line| log(line))
1087}
1088
1089fn down(options: &Options, dir: &Path) -> Result<()> {
1090 let mode = if options.flag("drain") { StopMode::Drain } else { StopMode::Now };
1091 if let Some(only) = only(options)? {
1092 return down_only(dir, mode, &only, options.flag("delete"));
1093 }
1094 native_down(dir, mode);
1095 let backend = backend(dir);
1096 if options.flag("delete") {
1097 backend.remove()?;
1098 let _ = std::fs::remove_dir_all(dir.join("images"));
1099 let _ = std::fs::remove_dir_all(native_dir(dir));
1100 let _ = std::fs::remove_dir_all(dir.join("carry"));
1101 let mut saved = Saved::load(dir);
1102 saved.image_version = None;
1103 saved.error = None;
1104 saved.save(dir)?;
1105 log("Deleted the sandbox. Its runners are still listed on g1t until you remove them there.");
1106 return Ok(());
1107 }
1108 if backend.exists() {
1109 backend.stop(mode)?;
1110 }
1111 let mut saved = Saved::load(dir);
1112 saved.error = None;
1113 saved.native_error = None;
1114 saved.save(dir)
1115}
1116
1117/// `down --only`: stops those capabilities and turns them off, leaving the
1118/// others as they are, running or not.
1119fn down_only(dir: &Path, mode: StopMode, only: &[&'static str], delete: bool) -> Result<()> {
1120 if delete {
1121 bail!("--delete removes the whole sandbox, so it takes no --only");
1122 }
1123 for kind in only {
1124 if *kind == "linux" {
1125 let backend = backend(dir);
1126 if backend.exists() && backend.running() {
1127 backend.stop(mode)?;
1128 }
1129 } else {
1130 native_down(dir, mode);
1131 }
1132 let mut saved = Saved::load(dir);
1133 if *kind == "linux" {
1134 saved.error = None;
1135 } else {
1136 saved.native_error = None;
1137 }
1138 saved.turn(kind, false);
1139 saved.save(dir)?;
1140 log(&format!("Stopped {} jobs; they stay off until `g1t-runner sandbox up --only {kind}`.", kind_name(kind)));
1141 }
1142 Ok(())
1143}
1144
1145fn update_sandbox(options: &Options, dir: &Path) -> Result<()> {
1146 let backend = backend(dir);
1147 if !backend.exists() {
1148 bail!("there is no sandbox to update: run `g1t-runner sandbox up`");
1149 }
1150 let saved = Saved::load(dir);
1151 let local = if options.value("image-file").is_some() {
1152 obtain_image(options, &saved, backend.as_ref(), dir)?
1153 } else {
1154 let manifest = image::latest(saved.site())?;
1155 if saved.image_version.as_deref().is_some_and(|current| !super::update::newer(&manifest.version, current)) {
1156 log(&format!("The sandbox's image is the newest, {}.", manifest.version));
1157 return Ok(());
1158 }
1159 let keys = backend.image_keys();
1160 if keys.is_empty() {
1161 obtain_image(options, &saved, backend.as_ref(), dir)?
1162 } else {
1163 image::obtain(saved.site(), &manifest, &keys, &dir.join("images"), &|line| log(line))?
1164 }
1165 };
1166 in_phase(dir, "updating", |saved| {
1167 let was_running = backend.running();
1168 if was_running {
1169 log("Draining: the runner finishes the job it is running, then stops.");
1170 backend.stop(StopMode::Drain)?;
1171 }
1172 // The registration, kept on the computer (in the private state
1173 // folder) only while no sandbox has it: from just before the old
1174 // system goes until the new one has it. Should the update stop in
1175 // between, the next `up` puts it in the new system and removes it.
1176 let carry = dir.join("carry");
1177 let carried = carry.join("config.json");
1178 if let Some(bytes) = backend.export_state()? {
1179 std::fs::create_dir_all(&carry)?;
1180 write_private(&carried, &bytes)?;
1181 }
1182 if let Err(error) = backend.remove_system() {
1183 // The old system, and the registration in it, are still there.
1184 let _ = std::fs::remove_dir_all(&carry);
1185 return Err(error);
1186 }
1187 backend.create(&local, &saved.resources)?;
1188 if let Ok(bytes) = std::fs::read(&carried) {
1189 backend.import_state(&bytes)?;
1190 }
1191 let _ = std::fs::remove_dir_all(&carry);
1192 if let Some(tar) = options.value("runner-image-file") {
1193 backend.load_image(Path::new(tar))?;
1194 }
1195 saved.image_version = Some(local.version.clone());
1196 if local.runner_image.is_some() {
1197 saved.runner_image = local.runner_image.clone();
1198 }
1199 backend.configure(&Settings {
1200 runner_image: saved.runner_image.clone().unwrap_or_default(),
1201 image_version: local.version.clone(),
1202 cpus: saved.resources.cpus,
1203 memory_mib: saved.resources.memory_mib,
1204 url: saved.url.clone(),
1205 name: Some(format!("{}-linux", base_name(saved))),
1206 labels: saved.labels.clone(),
1207 group: saved.group.clone(),
1208 machine: super::machine_fingerprint(),
1209 ..Settings::default()
1210 })?;
1211 if was_running {
1212 backend.start()?;
1213 wait_running(backend.as_ref(), Duration::from_secs(600))?;
1214 }
1215 image::prune(&dir.join("images"), &local.version);
1216 if local.version == "local" {
1217 log("The sandbox is on the image from --image-file.");
1218 } else {
1219 log(&format!("The sandbox is on image {}.", local.version));
1220 }
1221 Ok(())
1222 })
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227 use super::*;
1228
1229 #[test]
1230 fn the_default_share_leaves_the_computer_room() {
1231 assert_eq!(default_resources(16, Some(65536)), Resources { cpus: 8, memory_mib: 8192, disk_gib: 64 });
1232 assert_eq!(default_resources(8, Some(16384)), Resources { cpus: 4, memory_mib: 4096, disk_gib: 64 });
1233 assert_eq!(default_resources(4, Some(8192)), Resources { cpus: 2, memory_mib: 2048, disk_gib: 64 });
1234 // Never more than half a small machine's memory.
1235 assert_eq!(default_resources(2, Some(3072)).memory_mib, 1536);
1236 assert_eq!(default_resources(1, None).cpus, 1);
1237 assert_eq!(parse_gib("64g"), Some(64));
1238 assert_eq!(parse_gib("100000m"), Some(98));
1239 }
1240
1241 #[test]
1242 fn the_state_says_what_a_person_needs_to_know() {
1243 let running = GuestStatus { state: "running".into(), runner: Some(RunnerStatus { state: "idle".into(), ..RunnerStatus::default() }), ..GuestStatus::default() };
1244 let unregistered = GuestStatus { state: "running".into(), ..GuestStatus::default() };
1245 let failed = GuestStatus { state: "error".into(), error: Some("x".into()), ..GuestStatus::default() };
1246 assert_eq!(linux_state(true, false, false, None, None, None), "absent");
1247 assert_eq!(linux_state(false, false, false, None, None, Some("WSL is not installed.")), "error");
1248 assert_eq!(linux_state(true, true, false, None, None, None), "stopped");
1249 assert_eq!(linux_state(true, true, true, None, None, None), "starting");
1250 assert_eq!(linux_state(true, true, true, None, Some(&running), None), "running");
1251 assert_eq!(linux_state(true, true, true, None, Some(&unregistered), None), "starting");
1252 assert_eq!(linux_state(true, true, true, None, Some(&failed), None), "error");
1253 assert_eq!(linux_state(true, true, true, Some("updating"), Some(&running), None), "updating");
1254 assert_eq!(linux_state(true, false, false, Some("starting"), None, None), "starting");
1255 }
1256
1257 #[test]
1258 fn the_status_contract_holds_its_fields() {
1259 let status = Status {
1260 schema: SCHEMA,
1261 state: "running".into(),
1262 backend: Some("wsl2".into()),
1263 launcher_version: VERSION.into(),
1264 capabilities: vec![Capability { id: "linux".into(), available: true, state: "running".into(), ..Capability::default() }],
1265 ..Status::default()
1266 };
1267 let value = serde_json::to_value(&status).unwrap();
1268 for field in ["schema", "state", "backend", "runner_version", "launcher_version", "image_version", "isolation", "job", "error", "resources", "capabilities", "state_dir", "warnings"] {
1269 assert!(value.get(field).is_some(), "{field}");
1270 }
1271 for field in ["id", "available", "state", "isolation", "runner_version", "job", "cause", "fix", "turned_off", "needs"] {
1272 assert!(value["capabilities"][0].get(field).is_some(), "{field}");
1273 }
1274 assert_eq!(value["resources"]["memory_mib"], 0);
1275 }
1276
1277 #[test]
1278 fn a_status_with_nothing_made_is_absent_and_says_where() {
1279 let dir = std::env::temp_dir().join(format!("g1t-sandbox-status-{}", std::process::id()));
1280 let saved = Saved { phase: Some("updating".into()), phase_pid: Some(u32::MAX - 1), ..Saved::default() };
1281 saved.save(&dir).unwrap();
1282 // A launcher that died mid-update leaves no phase in the status.
1283 assert_ne!(status(&dir).state, "updating");
1284 assert_eq!(status(&dir).state_dir, dir.display().to_string());
1285 let _ = std::fs::remove_dir_all(&dir);
1286 }
1287
1288 #[test]
1289 fn only_names_what_this_computer_has() {
1290 let windows = ["linux", "windows"];
1291 assert_eq!(only_of("windows", &windows).unwrap(), ["windows"]);
1292 assert_eq!(only_of("linux, windows,linux", &windows).unwrap(), ["linux", "windows"]);
1293 assert_eq!(only_of("native", &windows).unwrap(), ["windows"]);
1294 assert!(only_of("macos", &windows).unwrap_err().to_string().contains("linux or windows"));
1295 assert!(only_of(",", &windows).is_err());
1296 // Linux has no native capability: `native` is not one of its kinds.
1297 assert!(only_of("native", &["linux"]).unwrap_err().to_string().contains("not native"));
1298 }
1299
1300 #[test]
1301 fn a_capability_turned_off_stays_off_and_reads_as_stopped() {
1302 let mut saved = Saved::default();
1303 saved.turn("windows", false);
1304 saved.turn("windows", false);
1305 assert_eq!(saved.off, ["windows"]);
1306 assert!(saved.is_off("windows") && !saved.is_off("linux"));
1307 saved.turn("windows", true);
1308 assert!(saved.off.is_empty());
1309 // Stopped by a person is not failing, and running is running whatever it says.
1310 assert_eq!(native_state(true, false, true, true), "stopped");
1311 assert_eq!(native_state(true, true, true, false), "running");
1312 assert_eq!(native_state(true, false, false, true), "error");
1313 assert_eq!(native_state(true, false, false, false), "stopped");
1314 assert_eq!(native_state(false, false, false, false), "absent");
1315 }
1316
1317 #[test]
1318 fn an_earlier_no_native_is_read_as_turned_off() {
1319 let dir = std::env::temp_dir().join(format!("g1t-sandbox-off-{}", std::process::id()));
1320 std::fs::create_dir_all(&dir).unwrap();
1321 std::fs::write(dir.join("sandbox.json"), r#"{"no_native":true,"labels":[]}"#).unwrap();
1322 let saved = Saved::load(&dir);
1323 assert!(!saved.no_native);
1324 assert_eq!(saved.off, native_id().into_iter().collect::<Vec<_>>());
1325 saved.save(&dir).unwrap();
1326 let written = std::fs::read_to_string(dir.join("sandbox.json")).unwrap();
1327 assert!(!written.contains("no_native"));
1328 let _ = std::fs::remove_dir_all(&dir);
1329 }
1330
1331 #[test]
1332 fn a_host_mode_runner_of_the_same_account_is_warned_of() {
1333 let root = std::env::temp_dir().join(format!("g1t-sandbox-host-{}", std::process::id()));
1334 let registered = |name: &str, how: &str| {
1335 let folder = root.join(name);
1336 std::fs::create_dir_all(&folder).unwrap();
1337 let config = format!(
1338 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}}}"#
1339 );
1340 std::fs::write(folder.join("config.json"), config).unwrap();
1341 folder
1342 };
1343 let host = registered("host", r#","docker":false"#);
1344 let docker = registered("docker", "");
1345 let native = registered("native", r#","docker":false,"native":true"#);
1346 let none = root.join("none");
1347 let found = host_mode_runners(&[host.clone(), docker, native, none]);
1348 assert_eq!(found.len(), 1);
1349 assert_eq!(found[0], host);
1350 let said = host_mode_warning(&host);
1351 assert!(said.contains("as you") && said.contains("g1t-runner remove --dir"), "{said}");
1352 // The desktop app's folder for running jobs directly is looked at.
1353 assert!(host_runner_folders().iter().any(|f| f.ends_with(Path::new("sh.g1t.desktop").join("runner").join("host"))));
1354 // On Windows, both where it keeps it now (local data) and where it
1355 // kept it before (roaming data).
1356 if cfg!(windows) {
1357 let folders = host_runner_folders();
1358 for var in ["LOCALAPPDATA", "APPDATA"] {
1359 if let Some(data) = std::env::var_os(var) {
1360 let expected = PathBuf::from(data).join("sh.g1t.desktop").join("runner").join("host");
1361 assert!(folders.contains(&expected), "{var}: {folders:?}");
1362 }
1363 }
1364 }
1365 // An older status, without warnings, still reads.
1366 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();
1367 assert!(old.warnings.is_empty());
1368 let _ = std::fs::remove_dir_all(&root);
1369 }
1370
1371 #[test]
1372 fn this_process_is_alive() {
1373 assert!(process_alive(std::process::id()));
1374 assert!(!process_alive(u32::MAX - 1));
1375 assert!(total_memory_mib().unwrap_or(1) > 0);
1376 }
1377}