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.
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 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 | ||
| 11 | use std::path::{Path, PathBuf}; | |
| 12 | use std::process::{Command, Stdio}; | |
| 13 | use std::time::{Duration, Instant}; | |
| 14 | ||
| 15 | use anyhow::{Context, Result, bail}; | |
| 16 | ||
| 17 | use super::guest::{GuestStatus, Settings, sysbox_run_args}; | |
| 18 | use super::image::Local; | |
| 19 | use super::{Backend, Resources, StopMode}; | |
| 20 | use crate::selfhosted::RunnerStatus; | |
| 21 | use crate::selfhosted::native::Availability; | |
| 22 | ||
| 23 | pub const CONTAINER: &str = "g1t-sandbox"; | |
| 24 | pub const VOLUME: &str = "g1t-sandbox-state"; | |
| 25 | ||
| 26 | pub struct SysboxHost { | |
| 27 | pub dir: PathBuf, | |
| 28 | } | |
| 29 | ||
| 30 | fn docker() -> Command { | |
| 31 | Command::new("docker") | |
| 32 | } | |
| 33 | ||
| 34 | fn 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. | |
| 39 | pub 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 | ||
| 43 | impl 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 | ||
| 63 | impl 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)] | |
| 204 | mod 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.