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.

g1t.sh
992 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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