Skip to content
223 linesCodeBlameRaw

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

Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.1//! Linux with Sysbox: no VM is needed. The runner's image runs on the
2//! machine's Docker Engine under `--runtime=sysbox-runc`, without the
3//! machine's socket, exactly as the self-hosted runners guide's container
4//! does; its registration is on a volume of its own, `g1t-sandbox-state`,
5//! which outlives the container, so an update replaces the container and
6//! keeps the runner. Two walls: the job's container, and the runner's,
7//! whose root is no one on the machine.
8//!
9//! Without Sysbox the launcher uses a VM instead (vm.rs, QEMU with KVM).
10
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13use std::time::{Duration, Instant};
14
15use anyhow::{Context, Result, bail};
16
17use super::guest::{GuestStatus, Settings, sysbox_run_args};
18use super::image::Local;
19use super::{Backend, Resources, StopMode};
20use crate::selfhosted::RunnerStatus;
21use crate::selfhosted::native::Availability;
22
23pub const CONTAINER: &str = "g1t-sandbox";
24pub const VOLUME: &str = "g1t-sandbox-state";
25
26pub struct SysboxHost {
27 pub dir: PathBuf,
28}
29
30fn docker() -> Command {
31 Command::new("docker")
32}
33
34fn ok(command: &mut Command) -> bool {
35 command.stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success())
36}
37
38/// Whether `docker info`'s runtimes have Sysbox's.
39pub fn has_sysbox(runtimes_json: &str) -> bool {
40 serde_json::from_str::<serde_json::Value>(runtimes_json.trim()).ok().is_some_and(|v| v.get("sysbox-runc").is_some())
41}
42
43impl SysboxHost {
44 fn settings_path(&self) -> PathBuf {
45 self.dir.join("settings.json")
46 }
47
48 fn settings(&self) -> Result<Settings> {
49 let bytes = std::fs::read(self.settings_path()).context("the sandbox has no settings: run `g1t-runner sandbox up`")?;
50 Ok(serde_json::from_slice(&bytes)?)
51 }
52
53 fn container_running(&self) -> Option<bool> {
54 let out = docker().args(["inspect", "-f", "{{.State.Running}}", CONTAINER]).output().ok()?;
55 out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim() == "true")
56 }
57
58 fn registered(&self, image: &str) -> bool {
59 ok(docker().args(["run", "--rm", "-v", &format!("{VOLUME}:/data"), "--entrypoint", "test", image, "-f", "/data/config.json"]))
60 }
61}
62
63impl Backend for SysboxHost {
64 fn name(&self) -> &'static str {
65 "sysbox"
66 }
67
68 fn image_keys(&self) -> Vec<String> {
69 Vec::new()
70 }
71
72 fn availability(&self) -> Availability {
73 let Ok(out) = docker().args(["info", "--format", "{{json .Runtimes}}"]).output() else {
74 return Availability::no("Docker is not installed.", "Install Docker Engine and Sysbox 0.7 or later (https://github.com/nestybox/sysbox), or QEMU with KVM for a VM.");
75 };
76 if !out.status.success() {
77 return Availability::no("Docker does not answer, or this user may not use it.", "Start Docker, and add this user to the docker group.");
78 }
79 if !has_sysbox(&String::from_utf8_lossy(&out.stdout)) {
80 return Availability::no("Docker has no Sysbox runtime.", "Install Sysbox 0.7 or later (https://github.com/nestybox/sysbox), or QEMU with KVM for a VM.");
81 }
82 Availability::yes()
83 }
84
85 fn exists(&self) -> bool {
86 self.container_running().is_some()
87 }
88
89 fn running(&self) -> bool {
90 self.container_running() == Some(true)
91 }
92
93 fn create(&self, local: &Local, _resources: &Resources) -> Result<()> {
94 let Some(image) = &local.runner_image else { bail!("the release names no runner image for the sandbox") };
95 if !ok(docker().args(["image", "inspect", image])) && !ok(docker().args(["pull", "-q", image])) {
96 bail!("could not fetch the runner's image {image}");
97 }
98 Ok(())
99 }
100
101 fn configure(&self, settings: &Settings) -> Result<()> {
102 std::fs::create_dir_all(&self.dir)?;
103 let mut merged = self.settings().unwrap_or_default();
104 let given = settings.clone();
105 merged = Settings {
106 url: given.url.or(merged.url),
107 token: given.token.or(merged.token),
108 name: given.name.or(merged.name),
109 labels: if given.labels.is_empty() { merged.labels } else { given.labels },
110 group: given.group.or(merged.group),
111 cpus: if given.cpus > 0 { given.cpus } else { merged.cpus },
112 memory_mib: if given.memory_mib > 0 { given.memory_mib } else { merged.memory_mib },
113 runner_image: if given.runner_image.is_empty() { merged.runner_image } else { given.runner_image },
114 image_version: if given.image_version.is_empty() { merged.image_version } else { given.image_version },
115 stop: None,
116 };
117 super::write_private(&self.settings_path(), &serde_json::to_vec_pretty(&merged)?)
118 }
119
120 fn load_image(&self, tar: &Path) -> Result<()> {
121 if !ok(docker().args(["load", "-i"]).arg(tar)) {
122 bail!("could not load {}", tar.display());
123 }
124 Ok(())
125 }
126
127 fn start(&self) -> Result<()> {
128 if self.running() {
129 return Ok(());
130 }
131 let mut settings = self.settings()?;
132 let _ = docker().args(["rm", "-f", CONTAINER]).stdout(Stdio::null()).stderr(Stdio::null()).status();
133 let register = !self.registered(&settings.runner_image);
134 let env_file = self.dir.join("register.env");
135 if register {
136 super::write_private(&env_file, super::guest::register_env(&settings)?.as_bytes())?;
137 }
138 let args = sysbox_run_args(&settings, CONTAINER, VOLUME, register.then(|| env_file.to_str().unwrap_or_default()));
139 let out = docker().args(&args).output().context("could not run docker")?;
140 let _ = std::fs::remove_file(&env_file);
141 if !out.status.success() {
142 bail!("could not start the runner: {}", String::from_utf8_lossy(&out.stderr).trim());
143 }
144 if register {
145 // The token goes once it has been handed over.
146 settings.token = None;
147 super::write_private(&self.settings_path(), &serde_json::to_vec_pretty(&settings)?)?;
148 }
149 Ok(())
150 }
151
152 fn guest_status(&self) -> Option<GuestStatus> {
153 let running = self.container_running()?;
154 let runner: Option<RunnerStatus> = docker()
155 .args(["exec", CONTAINER, "cat", "/data/status.json"])
156 .output()
157 .ok()
158 .filter(|o| o.status.success())
159 .and_then(|o| serde_json::from_slice(&o.stdout).ok());
160 Some(GuestStatus { state: if running { "running" } else { "stopped" }.into(), runner, ..GuestStatus::default() })
161 }
162
163 fn stop(&self, mode: StopMode) -> Result<()> {
164 if !self.running() {
165 return Ok(());
166 }
167 let file = if mode == StopMode::Drain { "/data/drain" } else { "/data/stop" };
168 let _ = docker().args(["exec", CONTAINER, "touch", file]).stdout(Stdio::null()).stderr(Stdio::null()).status();
169 let limit = (mode == StopMode::Now).then(|| Instant::now() + Duration::from_secs(90));
170 while self.running() {
171 if limit.is_some_and(|l| Instant::now() > l) {
172 let _ = docker().args(["stop", "-t", "30", CONTAINER]).stdout(Stdio::null()).stderr(Stdio::null()).status();
173 break;
174 }
175 std::thread::sleep(Duration::from_secs(1));
176 }
177 Ok(())
178 }
179
180 fn export_state(&self) -> Result<Option<Vec<u8>>> {
181 // The volume outlives the container.
182 Ok(None)
183 }
184
185 fn import_state(&self, _bytes: &[u8]) -> Result<()> {
186 Ok(())
187 }
188
189 fn remove_system(&self) -> Result<()> {
190 self.stop(StopMode::Now)?;
191 let _ = docker().args(["rm", "-f", CONTAINER]).stdout(Stdio::null()).stderr(Stdio::null()).status();
192 Ok(())
193 }
194
195 fn remove(&self) -> Result<()> {
196 self.remove_system()?;
197 let _ = docker().args(["volume", "rm", "-f", VOLUME]).stdout(Stdio::null()).stderr(Stdio::null()).status();
198 let _ = std::fs::remove_file(self.settings_path());
199 Ok(())
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 #[test]
208 fn sysbox_is_found_among_the_runtimes() {
209 assert!(has_sysbox(r#"{"io.containerd.runc.v2":{"path":"runc"},"runc":{"path":"runc"},"sysbox-runc":{"path":"/usr/bin/sysbox-runc"}}"#));
210 assert!(!has_sysbox(r#"{"runc":{"path":"runc"}}"#));
211 assert!(!has_sysbox("not json"));
212 }
213
214 #[test]
215 fn on_linux_the_runner_keeps_its_registration_on_a_volume() {
216 let settings = Settings { runner_image: "g1t.sh/flagon-io/g1t-runner@sha256:ab".into(), ..Settings::default() };
217 let args = sysbox_run_args(&settings, CONTAINER, VOLUME, Some("/s/register.env")).join(" ");
218 assert!(args.contains("--name g1t-sandbox --runtime=sysbox-runc"));
219 assert!(args.contains("-v g1t-sandbox-state:/data"));
220 assert!(args.contains("--env-file /s/register.env"));
221 assert!(!args.contains("docker.sock"));
222 }
223}