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