Skip to content
223 linesCodeBlameRaw
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}