Skip to content
998 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//! [--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 // From G1T_RUNNER_TOKEN too, as register-and-run takes it: a token on
734 // the command line shows in the process list to anyone on the machine
735 // while `up` runs, and the desktop app passes it this way.
736 let token = options
737 .value("token")
738 .map(str::to_owned)
739 .or_else(|| std::env::var("G1T_RUNNER_TOKEN").ok().filter(|token| !token.trim().is_empty()));
740
741 let backend = backend(dir);
742 let found = backend.availability();
743 let linux = if found.available {
744 in_phase(dir, "starting", |saved| {
745 let fresh = !backend.exists();
746 if fresh {
747 let local = obtain_image(options, saved, backend.as_ref(), dir)?;
748 backend.create(&local, &saved.resources)?;
749 saved.image_version = Some(local.version.clone());
750 if local.runner_image.is_some() {
751 saved.runner_image = local.runner_image.clone();
752 }
753 // An update that stopped half-way left the registration here.
754 let carried = dir.join("carry").join("config.json");
755 if let Ok(bytes) = std::fs::read(&carried) {
756 backend.import_state(&bytes)?;
757 let _ = std::fs::remove_file(&carried);
758 }
759 }
760 if let Some(tar) = options.value("runner-image-file") {
761 backend.load_image(Path::new(tar))?;
762 }
763 let Some(runner_image) = saved.runner_image.clone() else {
764 bail!("there is no runner image for the sandbox: this build is not a release, so pass --runner-image or --runner-image-file");
765 };
766 backend.configure(&Settings {
767 url: saved.url.clone(),
768 token: token.clone(),
769 name: Some(format!("{}-linux", base_name(saved))),
770 labels: saved.labels.clone(),
771 group: saved.group.clone(),
772 cpus: saved.resources.cpus,
773 memory_mib: saved.resources.memory_mib,
774 runner_image,
775 image_version: saved.image_version.clone().unwrap_or_default(),
776 stop: None,
777 })?;
778 backend.start()?;
779 wait_running(backend.as_ref(), Duration::from_secs(if fresh { 600 } else { 180 }))
780 })
781 } else {
782 Err(anyhow!("{} {}", found.cause.unwrap_or_default(), found.fix.unwrap_or_default()))
783 };
784 let native = match native_capability() {
785 Some((os, found)) if found.available && !saved.no_native => native_up(dir, &Saved::load(dir), token.as_deref(), os).map(|()| true),
786 _ => Ok(false),
787 };
788 match (linux, native) {
789 (Ok(()), Ok(_)) => Ok(()),
790 (Err(linux), Ok(true)) => Err(linux.context("Windows jobs are sandboxed and running, but Linux jobs are not")),
791 (Err(linux), _) => Err(linux),
792 (Ok(()), Err(native)) => Err(native.context("Linux jobs are sandboxed and running, but jobs native to this OS are not")),
793 }
794}
795
796/// Waits until the sandbox's runner is registered and asking for work (it
797/// has written its status), or says what went wrong.
798fn wait_running(backend: &dyn Backend, limit: Duration) -> Result<()> {
799 let until = Instant::now() + limit;
800 loop {
801 if let Some(status) = backend.guest_status() {
802 match status.state.as_str() {
803 "running" if status.runner.as_ref().is_some_and(|r| r.state != "stopped") => return Ok(()),
804 "error" => bail!("{}", status.error.unwrap_or_else(|| "the sandbox stopped".into())),
805 _ => {}
806 }
807 }
808 if Instant::now() > until {
809 bail!("the sandbox did not start within {} seconds; `g1t-runner sandbox status` says how it is now", limit.as_secs());
810 }
811 std::thread::sleep(Duration::from_secs(1));
812 }
813}
814
815fn obtain_image(options: &Options, saved: &Saved, backend: &dyn Backend, dir: &Path) -> Result<image::Local> {
816 let keys = backend.image_keys();
817 if let Some(file) = options.value("image-file") {
818 log(&format!("Using {file}, which is not a release: it is not checked against g1t's signature."));
819 return image::local(&keys, Path::new(file), saved.runner_image.clone());
820 }
821 if keys.is_empty() {
822 // Sysbox on Linux: only the runner's image, named in the release.
823 let manifest = image::latest(saved.site());
824 return Ok(image::Local {
825 version: manifest.as_ref().map(|m| m.version.clone()).unwrap_or_else(|_| VERSION.to_owned()),
826 files: Default::default(),
827 runner_image: manifest.ok().and_then(|m| m.sandbox).and_then(|s| s.runner_image).or_else(|| saved.runner_image.clone()),
828 });
829 }
830 let manifest = image::latest(saved.site())?;
831 image::obtain(saved.site(), &manifest, &keys, &dir.join("images"), &|line| log(line))
832}
833
834fn down(options: &Options, dir: &Path) -> Result<()> {
835 let mode = if options.flag("drain") { StopMode::Drain } else { StopMode::Now };
836 native_down(dir, mode);
837 let backend = backend(dir);
838 if options.flag("delete") {
839 backend.remove()?;
840 let _ = std::fs::remove_dir_all(dir.join("images"));
841 let _ = std::fs::remove_dir_all(native_dir(dir));
842 let _ = std::fs::remove_dir_all(dir.join("carry"));
843 let mut saved = Saved::load(dir);
844 saved.image_version = None;
845 saved.error = None;
846 saved.save(dir)?;
847 log("Deleted the sandbox. Its runners are still listed on g1t until you remove them there.");
848 return Ok(());
849 }
850 if backend.exists() {
851 backend.stop(mode)?;
852 }
853 let mut saved = Saved::load(dir);
854 saved.error = None;
855 saved.save(dir)
856}
857
858fn update_sandbox(options: &Options, dir: &Path) -> Result<()> {
859 let backend = backend(dir);
860 if !backend.exists() {
861 bail!("there is no sandbox to update: run `g1t-runner sandbox up`");
862 }
863 let saved = Saved::load(dir);
864 let local = if options.value("image-file").is_some() {
865 obtain_image(options, &saved, backend.as_ref(), dir)?
866 } else {
867 let manifest = image::latest(saved.site())?;
868 if saved.image_version.as_deref().is_some_and(|current| !super::update::newer(&manifest.version, current)) {
869 log(&format!("The sandbox's image is the newest, {}.", manifest.version));
870 return Ok(());
871 }
872 let keys = backend.image_keys();
873 if keys.is_empty() {
874 obtain_image(options, &saved, backend.as_ref(), dir)?
875 } else {
876 image::obtain(saved.site(), &manifest, &keys, &dir.join("images"), &|line| log(line))?
877 }
878 };
879 in_phase(dir, "updating", |saved| {
880 let was_running = backend.running();
881 if was_running {
882 log("Draining: the runner finishes the job it is running, then stops.");
883 backend.stop(StopMode::Drain)?;
884 }
885 // The registration, kept on the computer until the new sandbox has it.
886 let carried = dir.join("carry").join("config.json");
887 if let Some(bytes) = backend.export_state()? {
888 std::fs::create_dir_all(dir.join("carry"))?;
889 write_private(&carried, &bytes)?;
890 }
891 backend.remove_system()?;
892 backend.create(&local, &saved.resources)?;
893 if let Ok(bytes) = std::fs::read(&carried) {
894 backend.import_state(&bytes)?;
895 let _ = std::fs::remove_file(&carried);
896 }
897 if let Some(tar) = options.value("runner-image-file") {
898 backend.load_image(Path::new(tar))?;
899 }
900 saved.image_version = Some(local.version.clone());
901 if local.runner_image.is_some() {
902 saved.runner_image = local.runner_image.clone();
903 }
904 backend.configure(&Settings {
905 runner_image: saved.runner_image.clone().unwrap_or_default(),
906 image_version: local.version.clone(),
907 cpus: saved.resources.cpus,
908 memory_mib: saved.resources.memory_mib,
909 url: saved.url.clone(),
910 name: Some(format!("{}-linux", base_name(saved))),
911 labels: saved.labels.clone(),
912 group: saved.group.clone(),
913 ..Settings::default()
914 })?;
915 if was_running {
916 backend.start()?;
917 wait_running(backend.as_ref(), Duration::from_secs(600))?;
918 }
919 image::prune(&dir.join("images"), &local.version);
920 if local.version == "local" {
921 log("The sandbox is on the image from --image-file.");
922 } else {
923 log(&format!("The sandbox is on image {}.", local.version));
924 }
925 Ok(())
926 })
927}
928
929#[cfg(test)]
930mod tests {
931 use super::*;
932
933 #[test]
934 fn the_default_share_leaves_the_computer_room() {
935 assert_eq!(default_resources(16, Some(65536)), Resources { cpus: 8, memory_mib: 8192, disk_gib: 64 });
936 assert_eq!(default_resources(8, Some(16384)), Resources { cpus: 4, memory_mib: 4096, disk_gib: 64 });
937 assert_eq!(default_resources(4, Some(8192)), Resources { cpus: 2, memory_mib: 2048, disk_gib: 64 });
938 // Never more than half a small machine's memory.
939 assert_eq!(default_resources(2, Some(3072)).memory_mib, 1536);
940 assert_eq!(default_resources(1, None).cpus, 1);
941 assert_eq!(parse_gib("64g"), Some(64));
942 assert_eq!(parse_gib("100000m"), Some(98));
943 }
944
945 #[test]
946 fn the_state_says_what_a_person_needs_to_know() {
947 let running = GuestStatus { state: "running".into(), runner: Some(RunnerStatus { state: "idle".into(), ..RunnerStatus::default() }), ..GuestStatus::default() };
948 let unregistered = GuestStatus { state: "running".into(), ..GuestStatus::default() };
949 let failed = GuestStatus { state: "error".into(), error: Some("x".into()), ..GuestStatus::default() };
950 assert_eq!(linux_state(true, false, false, None, None, None), "absent");
951 assert_eq!(linux_state(false, false, false, None, None, Some("WSL is not installed.")), "error");
952 assert_eq!(linux_state(true, true, false, None, None, None), "stopped");
953 assert_eq!(linux_state(true, true, true, None, None, None), "starting");
954 assert_eq!(linux_state(true, true, true, None, Some(&running), None), "running");
955 assert_eq!(linux_state(true, true, true, None, Some(&unregistered), None), "starting");
956 assert_eq!(linux_state(true, true, true, None, Some(&failed), None), "error");
957 assert_eq!(linux_state(true, true, true, Some("updating"), Some(&running), None), "updating");
958 assert_eq!(linux_state(true, false, false, Some("starting"), None, None), "starting");
959 }
960
961 #[test]
962 fn the_status_contract_holds_its_fields() {
963 let status = Status {
964 schema: SCHEMA,
965 state: "running".into(),
966 backend: Some("wsl2".into()),
967 launcher_version: VERSION.into(),
968 capabilities: vec![Capability { id: "linux".into(), available: true, state: "running".into(), ..Capability::default() }],
969 ..Status::default()
970 };
971 let value = serde_json::to_value(&status).unwrap();
972 for field in ["schema", "state", "backend", "runner_version", "launcher_version", "image_version", "isolation", "job", "error", "resources", "capabilities", "state_dir"] {
973 assert!(value.get(field).is_some(), "{field}");
974 }
975 for field in ["id", "available", "state", "isolation", "runner_version", "job", "cause", "fix"] {
976 assert!(value["capabilities"][0].get(field).is_some(), "{field}");
977 }
978 assert_eq!(value["resources"]["memory_mib"], 0);
979 }
980
981 #[test]
982 fn a_status_with_nothing_made_is_absent_and_says_where() {
983 let dir = std::env::temp_dir().join(format!("g1t-sandbox-status-{}", std::process::id()));
984 let saved = Saved { phase: Some("updating".into()), phase_pid: Some(u32::MAX - 1), ..Saved::default() };
985 saved.save(&dir).unwrap();
986 // A launcher that died mid-update leaves no phase in the status.
987 assert_ne!(status(&dir).state, "updating");
988 assert_eq!(status(&dir).state_dir, dir.display().to_string());
989 let _ = std::fs::remove_dir_all(&dir);
990 }
991
992 #[test]
993 fn this_process_is_alive() {
994 assert!(process_alive(std::process::id()));
995 assert!(!process_alive(u32::MAX - 1));
996 assert!(total_memory_mib().unwrap_or(1) > 0);
997 }
998}