Skip to content
419 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.

Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1//! Docker Engines of the runner's own, for `nested-container` isolation
2//! (sandbox.rs):
3//!
4//! - **The runner's engine.** In the runner's image under Sysbox
5//! (`G1T_RUNNER_SANDBOX=nested`), the runner starts `dockerd` inside its
6//! own container and runs every job's container on it. It is a child of
7//! the runner, which `docker-init` (tini) supervises as PID 1, and is
8//! started again before a job if it stopped. Started the way g1t's own
9//! sandboxes start a job's engine (crate::docker::engine): the cgroup
10//! controllers handed down first, as moby's `hack/dind` does, and plain
11//! copies when the disk takes no overlays. Unlike there, Sysbox lets it
12//! keep its packet filter, so each job gets a bridge network of its own.
13//! - **A job's engine.** A job that uses Docker (`services:`, a Docker
14//! action, `docker` in a step) gets an engine of its own, started the
15//! first time the job's Docker socket is used: a `docker:dind` container
16//! beside the job's, in the job's network namespace, so what it
17//! publishes is on the job's `localhost`. The job sees only a socket
18//! that the runner passes on. The engine is privileged within the
19//! runner's container, never on the machine (Sysbox maps its root to an
20//! unprivileged user there), and is removed with its data after the job.
21
22use std::io::{self, Read, Write};
23use std::os::unix::net::{UnixListener, UnixStream};
24use std::path::{Path, PathBuf};
25use std::process::{Child, Command, Stdio};
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::sync::{Arc, Mutex, OnceLock};
28use std::time::{Duration, Instant};
29
30use anyhow::{Context, Result, bail};
31use serde_json::{Value, json};
32
33use super::log;
34
35/// Everything of the runner's engine that is not its data.
36pub const DIR: &str = "/run/g1t-runner";
37/// The runner's engine's socket: not `/var/run/docker.sock`, so a socket
38/// there is never the runner's own (sandbox.rs looks for one).
39pub const SOCKET: &str = "/run/g1t-runner/docker.sock";
40/// Where a job's sockets are kept, a folder each.
41pub const JOBS_DIR: &str = "/run/g1t-runner/jobs";
42/// Where the job sees its Docker socket.
43pub const JOB_SOCKET_DIR: &str = "/run/g1t-docker";
44/// The Docker CLI in the runner's image, lent to a job read-only.
45pub const DOCKER_CLI: &str = "/usr/local/bin/docker";
46/// The image a job's engine runs.
47pub const DEFAULT_JOB_ENGINE_IMAGE: &str = "docker:28-dind";
48
49const LOG: &str = "/run/g1t-runner/dockerd.log";
50/// From moby's `hack/dind`, as crate::docker::engine does it.
51const CGROUP_NESTING: &str = "if [ -f /sys/fs/cgroup/cgroup.controllers ] && [ -z \"$(cat /sys/fs/cgroup/cgroup.subtree_control)\" ]; then \
52 mkdir -p /sys/fs/cgroup/init && xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null; \
53 sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers > /sys/fs/cgroup/cgroup.subtree_control; fi";
54
55/// The engine the runner's `docker` commands use: its own, once started.
56static HOST: OnceLock<String> = OnceLock::new();
57/// The runner's engine, while it runs.
58static ENGINE: Mutex<Option<Child>> = Mutex::new(None);
59
60/// Whether the runner uses an engine of its own.
61pub fn in_use() -> bool {
62 HOST.get().is_some()
63}
64
65/// `docker`, pointed at the runner's own engine when it has one.
66pub fn docker() -> Command {
67 let mut command = Command::new("docker");
68 if let Some(host) = HOST.get() {
69 command.env("DOCKER_HOST", host);
70 }
71 command
72}
73
74/// The runner's engine's `daemon.json`. `copies`: the disk takes no
75/// overlays. Its own address ranges, away from Docker's defaults, which
76/// the machine's engine (and so the runner's own address) already uses.
77pub fn daemon_config(copies: bool) -> Value {
78 let mut config = json!({
79 "hosts": [format!("unix://{SOCKET}")],
80 "pidfile": format!("{DIR}/dockerd.pid"),
81 "bip": "172.31.254.1/24",
82 "default-address-pools": [{ "base": "172.30.0.0/16", "size": 24 }],
83 "registry-mirrors": [crate::docker::engine::MIRROR],
84 "log-driver": "json-file",
85 "log-opts": { "max-size": "20m", "max-file": "2" },
86 });
87 if copies {
88 config["storage-driver"] = json!("vfs");
89 }
90 config
91}
92
93/// A request to an engine's socket; its status.
94fn ping(socket: &Path) -> Option<u16> {
95 let mut stream = UnixStream::connect(socket).ok()?;
96 stream.set_read_timeout(Some(Duration::from_secs(5))).ok()?;
97 stream.write_all(b"GET /_ping HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n").ok()?;
98 let mut head = [0u8; 12];
99 stream.read_exact(&mut head).ok()?;
100 std::str::from_utf8(&head[9..12]).ok()?.parse().ok()
101}
102
103fn tail(path: &Path, lines: usize) -> String {
104 let text = std::fs::read_to_string(path).unwrap_or_default();
105 let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
106 all[all.len().saturating_sub(lines)..].join("\n")
107}
108
109/// Starts the runner's own engine, or finds it running (the runner
110/// restarted after an update); its version.
111pub fn start() -> Result<String> {
112 let _ = HOST.set(format!("unix://{SOCKET}"));
113 let mut engine = ENGINE.lock().unwrap_or_else(|p| p.into_inner());
114 if let Some(child) = engine.as_mut()
115 && matches!(child.try_wait(), Ok(None))
116 && ping(Path::new(SOCKET)) == Some(200)
117 {
118 return Ok(version());
119 }
120 if engine.is_none() && ping(Path::new(SOCKET)) == Some(200) {
121 return Ok(version());
122 }
123 std::fs::create_dir_all(DIR).with_context(|| format!("could not make {DIR}"))?;
124 let _ = Command::new("sh").args(["-c", CGROUP_NESTING]).status();
125 let mut last = String::new();
126 for copies in [false, true] {
127 std::fs::write(format!("{DIR}/daemon.json"), serde_json::to_vec_pretty(&daemon_config(copies))?)?;
128 let _ = std::fs::remove_file(SOCKET);
129 let out = std::fs::File::create(LOG).with_context(|| format!("could not write {LOG}"))?;
130 let err = out.try_clone()?;
131 let mut child = Command::new("dockerd")
132 .args(["--config-file", &format!("{DIR}/daemon.json")])
133 .env_remove("DOCKER_HOST")
134 .stdin(Stdio::null())
135 .stdout(out)
136 .stderr(err)
137 .spawn()
138 .context("could not run dockerd: the runner's image has it; is this the runner's image?")?;
139 let until = Instant::now() + Duration::from_secs(90);
140 loop {
141 if ping(Path::new(SOCKET)) == Some(200) {
142 *engine = Some(child);
143 let version = version();
144 return Ok(if copies { format!("{version} (plain-copy storage: this disk takes no overlays)") } else { version });
145 }
146 if let Ok(Some(status)) = child.try_wait() {
147 last = format!("dockerd stopped ({status}):\n{}", tail(Path::new(LOG), 15));
148 break;
149 }
150 if Instant::now() >= until {
151 let _ = child.kill();
152 let _ = child.wait();
153 last = format!("dockerd did not answer in 90 s:\n{}", tail(Path::new(LOG), 15));
154 break;
155 }
156 std::thread::sleep(Duration::from_millis(200));
157 }
158 }
159 bail!(
160 "the runner's own Docker Engine could not start. Under Sysbox it needs no more than `docker run --runtime=sysbox-runc`; without Sysbox it cannot start without --privileged, which this runner does not use.\n{last}"
161 )
162}
163
164/// Before a job: the runner's engine is running, started again if not.
165pub fn ensure_running() -> Result<()> {
166 let stopped = {
167 let mut engine = ENGINE.lock().unwrap_or_else(|p| p.into_inner());
168 match engine.as_mut() {
169 Some(child) => !matches!(child.try_wait(), Ok(None)),
170 None => ping(Path::new(SOCKET)) != Some(200),
171 }
172 };
173 if stopped {
174 log("The runner's Docker Engine stopped; starting it again.");
175 start()?;
176 }
177 Ok(())
178}
179
180fn version() -> String {
181 docker()
182 .args(["version", "--format", "{{.Server.Version}}"])
183 .output()
184 .ok()
185 .filter(|o| o.status.success())
186 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
187 .filter(|v| !v.is_empty())
188 .unwrap_or_else(|| "?".into())
189}
190
191// --- A job's own engine -------------------------------------------------
192
193/// A job's Docker: a socket now, an engine the first time it is used.
194pub struct JobEngine {
195 /// `JOBS_DIR/<job container>`: `proxy/docker.sock` (the job's) and
196 /// `engine/docker.sock` (its engine's).
197 pub dir: PathBuf,
198 /// The engine's container and its data's volume.
199 pub container: String,
200 pub volume: String,
201 stop: Arc<AtomicBool>,
202}
203
204/// What starting it needs.
205#[derive(Clone)]
206struct EngineSpec {
207 image: String,
208 container: String,
209 volume: String,
210 /// The job's container, whose network the engine joins.
211 job: String,
212 label: String,
213 engine_dir: PathBuf,
214}
215
216/// The `docker run` for a job's engine. Privileged within the runner's
217/// container: Sysbox maps its root to an unprivileged user on the
218/// machine, and gives the runner's container no devices of the machine's.
219fn engine_args(spec: &EngineSpec) -> Vec<String> {
220 let mut args: Vec<String> = ["run", "-d", "--rm", "--name", &spec.container, "--label", &spec.label, "--privileged", "--pull", "missing"]
221 .iter()
222 .map(|s| s.to_string())
223 .collect();
224 args.extend(["--network".into(), format!("container:{}", spec.job)]);
225 // No TLS certificates and no TCP port: only the socket, which the
226 // runner passes on.
227 args.extend(["-e".into(), "DOCKER_TLS_CERTDIR=".into()]);
228 args.extend(["-v".into(), format!("{}:/run/g1t-engine", spec.engine_dir.display())]);
229 args.extend(["-v".into(), format!("{}:/var/lib/docker", spec.volume)]);
230 args.extend([spec.image.clone(), "dockerd".into(), "--host=unix:///run/g1t-engine/docker.sock".into()]);
231 args
232}
233
234fn start_job_engine(spec: &EngineSpec) -> Result<(), String> {
235 let begun = Instant::now();
236 let output = docker().args(engine_args(spec)).output().map_err(|e| format!("could not run docker: {e}"))?;
237 if !output.status.success() {
238 return Err(format!("could not start the job's Docker Engine: {}", String::from_utf8_lossy(&output.stderr).trim()));
239 }
240 let socket = spec.engine_dir.join("docker.sock");
241 while begun.elapsed() < Duration::from_secs(120) {
242 if ping(&socket) == Some(200) {
243 log(&format!("Started the job's own Docker Engine in {:.1}s.", begun.elapsed().as_secs_f64()));
244 return Ok(());
245 }
246 std::thread::sleep(Duration::from_millis(200));
247 }
248 Err("the job's Docker Engine did not answer in 120 s".into())
249}
250
251/// Passes one connection on to the job's engine, starting it first.
252fn serve(mut client: UnixStream, spec: &EngineSpec, started: &Mutex<Option<Result<(), String>>>) {
253 let ready = {
254 let mut started = started.lock().unwrap_or_else(|p| p.into_inner());
255 if started.is_none() {
256 *started = Some(start_job_engine(spec));
257 }
258 started.clone().unwrap_or(Ok(()))
259 };
260 if let Err(problem) = ready {
261 let body = serde_json::to_string(&json!({ "message": problem })).unwrap_or_default();
262 let _ = write!(client, "HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
263 return;
264 }
265 let Ok(engine) = UnixStream::connect(spec.engine_dir.join("docker.sock")) else { return };
266 let (Ok(mut client_read), Ok(mut engine_write)) = (client.try_clone(), engine.try_clone()) else { return };
267 let upstream = std::thread::spawn(move || {
268 let _ = io::copy(&mut client_read, &mut engine_write);
269 let _ = engine_write.shutdown(std::net::Shutdown::Write);
270 });
271 let mut engine_read = engine;
272 let _ = io::copy(&mut engine_read, &mut client);
273 let _ = client.shutdown(std::net::Shutdown::Both);
274 let _ = upstream.join();
275}
276
277impl JobEngine {
278 /// Sets a job's Docker socket up, with no engine behind it yet.
279 pub fn prepare(job: &str, runner: &str, image: &str) -> Result<JobEngine> {
280 let dir = Path::new(JOBS_DIR).join(job);
281 let _ = std::fs::remove_dir_all(&dir);
282 let (proxy, engine_dir) = (dir.join("proxy"), dir.join("engine"));
283 std::fs::create_dir_all(&proxy).and_then(|()| std::fs::create_dir_all(&engine_dir)).with_context(|| format!("could not make {}", dir.display()))?;
284 let socket = proxy.join("docker.sock");
285 let listener = UnixListener::bind(&socket).with_context(|| format!("could not listen on {}", socket.display()))?;
286 {
287 use std::os::unix::fs::PermissionsExt;
288 // Whoever the job's image runs as.
289 std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o666))?;
290 }
291 listener.set_nonblocking(true)?;
292 let spec = EngineSpec {
293 image: image.to_owned(),
294 container: format!("{job}-docker"),
295 volume: format!("{job}-docker"),
296 job: job.to_owned(),
297 label: format!("sh.g1t.runner={runner}"),
298 engine_dir,
299 };
300 let stop = Arc::new(AtomicBool::new(false));
301 let (stopping, thread_spec) = (stop.clone(), spec.clone());
302 std::thread::spawn(move || {
303 let started: Arc<Mutex<Option<Result<(), String>>>> = Arc::new(Mutex::new(None));
304 while !stopping.load(Ordering::SeqCst) {
305 match listener.accept() {
306 Ok((client, _)) => {
307 let _ = client.set_nonblocking(false);
308 let (spec, started) = (thread_spec.clone(), started.clone());
309 std::thread::spawn(move || serve(client, &spec, &started));
310 }
311 Err(error) if error.kind() == io::ErrorKind::WouldBlock => std::thread::sleep(Duration::from_millis(100)),
312 Err(_) => std::thread::sleep(Duration::from_millis(100)),
313 }
314 }
315 });
316 Ok(JobEngine { dir, container: spec.container, volume: spec.volume, stop })
317 }
318
319 /// What the job's `docker run` adds: its socket's folder, read-only
320 /// (a socket on a read-only mount still connects), the Docker CLI, and
321 /// `DOCKER_HOST`.
322 pub fn job_args(&self) -> Vec<String> {
323 let mut args = vec!["-v".into(), format!("{}:{JOB_SOCKET_DIR}:ro", self.dir.join("proxy").display())];
324 if Path::new(DOCKER_CLI).exists() {
325 args.extend(["-v".into(), format!("{DOCKER_CLI}:/usr/local/bin/docker:ro")]);
326 }
327 args.extend(["-e".into(), format!("DOCKER_HOST=unix://{JOB_SOCKET_DIR}/docker.sock")]);
328 args
329 }
330
331 /// Stops the engine and removes it, its data and its sockets.
332 pub fn remove(&self) {
333 self.stop.store(true, Ordering::SeqCst);
334 let quiet = |args: &[&str]| {
335 let _ = docker().args(args).stdout(Stdio::null()).stderr(Stdio::null()).status();
336 };
337 quiet(&["rm", "-f", "-v", &self.container]);
338 quiet(&["volume", "rm", "-f", &self.volume]);
339 let _ = std::fs::remove_dir_all(&self.dir);
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn the_runners_engine_keeps_its_own_socket_and_addresses() {
349 let config = daemon_config(false);
350 assert_eq!(config["hosts"][0], "unix:///run/g1t-runner/docker.sock");
351 assert_ne!(SOCKET, "/var/run/docker.sock");
352 assert!(config.get("storage-driver").is_none());
353 assert_eq!(daemon_config(true)["storage-driver"], "vfs");
354 // Not Docker's default range, which the machine's engine has.
355 assert!(!config["bip"].as_str().unwrap().starts_with("172.17."));
356 }
357
358 #[test]
359 fn a_jobs_engine_is_in_the_jobs_network_and_mounts_nothing_of_the_machine() {
360 let spec = EngineSpec {
361 image: DEFAULT_JOB_ENGINE_IMAGE.into(),
362 container: "g1t-job_1-docker".into(),
363 volume: "g1t-job_1-docker".into(),
364 job: "g1t-job_1".into(),
365 label: "sh.g1t.runner=rnr_1".into(),
366 engine_dir: PathBuf::from("/run/g1t-runner/jobs/g1t-job_1/engine"),
367 };
368 let args = engine_args(&spec);
369 let joined = args.join(" ");
370 assert!(joined.contains("--network container:g1t-job_1"));
371 assert!(joined.contains("--rm"));
372 assert!(!joined.contains("docker.sock:/"), "no engine socket is mounted in: {joined}");
373 // Its volumes: the runner's own folder for its socket, and a volume.
374 let mounts: Vec<&String> = args.windows(2).filter(|w| w[0] == "-v").map(|w| &w[1]).collect();
375 assert_eq!(mounts.len(), 2);
376 assert!(mounts[0].starts_with("/run/g1t-runner/jobs/"));
377 assert!(mounts[1].starts_with("g1t-job_1-docker:"));
378 assert!(args.iter().any(|a| a == "DOCKER_TLS_CERTDIR="));
379 }
380
381 /// Against a real Docker Engine, as root, with `docker:28-dind` at
382 /// hand: `G1T_TEST_DOCKER=1 cargo test -- --ignored job_engine`.
383 #[test]
384 #[ignore]
385 fn a_jobs_engine_starts_when_the_job_first_uses_it() {
386 if std::env::var("G1T_TEST_DOCKER").as_deref() != Ok("1") {
387 return;
388 }
389 let job = "g1t-engine-test";
390 let quiet = |args: &[&str]| {
391 let _ = docker().args(args).stdout(Stdio::null()).stderr(Stdio::null()).status();
392 };
393 quiet(&["rm", "-f", job]);
394 let engine = JobEngine::prepare(job, "test", DEFAULT_JOB_ENGINE_IMAGE).unwrap();
395 // A job's container with the job's Docker: the dind image has a CLI.
396 let mut run = docker();
397 run.args(["run", "-d", "--rm", "--name", job, "--cap-drop", "ALL", "--security-opt", "no-new-privileges"]);
398 let mut job_args = engine.job_args();
399 // This machine's CLI may not run in Alpine; the image has its own.
400 if let Some(at) = job_args.iter().position(|a| a.starts_with(DOCKER_CLI)) {
401 job_args.drain(at - 1..=at);
402 }
403 run.args(&job_args).args(["--entrypoint", "sleep", DEFAULT_JOB_ENGINE_IMAGE, "300"]);
404 assert!(run.status().unwrap().success());
405 // Nothing runs behind the socket until it is used.
406 assert!(!docker().args(["inspect", &engine.container]).stdout(Stdio::null()).stderr(Stdio::null()).status().unwrap().success());
407 let version = docker().args(["exec", job, "docker", "version", "--format", "{{.Server.Version}}"]).output().unwrap();
408 let shown = String::from_utf8_lossy(&version.stdout).trim().to_owned();
409 let errors = String::from_utf8_lossy(&version.stderr).trim().to_owned();
410 // The job cannot change its socket's folder.
411 let wrote = docker().args(["exec", job, "touch", "/run/g1t-docker/x"]).stderr(Stdio::null()).status().unwrap();
412 quiet(&["rm", "-f", job]);
413 engine.remove();
414 assert!(version.status.success(), "docker version in the job: {errors}");
415 assert!(shown.starts_with("28."), "{shown}");
416 assert!(!wrote.success());
417 assert!(!engine.dir.exists());
418 }
419}

This file's history is long; its oldest lines are credited to the oldest commit read.