| 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 | } |