Skip to content

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

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