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 | ||
| 22 | use std::io::{self, Read, Write}; | |
| 23 | use std::os::unix::net::{UnixListener, UnixStream}; | |
| 24 | use std::path::{Path, PathBuf}; | |
| 25 | use std::process::{Child, Command, Stdio}; | |
| 26 | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 27 | use std::sync::{Arc, Mutex, OnceLock}; | |
| 28 | use std::time::{Duration, Instant}; | |
| 29 | ||
| 30 | use anyhow::{Context, Result, bail}; | |
| 31 | use serde_json::{Value, json}; | |
| 32 | ||
| 33 | use super::log; | |
| 34 | ||
| 35 | /// Everything of the runner's engine that is not its data. | |
| 36 | pub 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). | |
| 39 | pub const SOCKET: &str = "/run/g1t-runner/docker.sock"; | |
| 40 | /// Where a job's sockets are kept, a folder each. | |
| 41 | pub const JOBS_DIR: &str = "/run/g1t-runner/jobs"; | |
| 42 | /// Where the job sees its Docker socket. | |
| 43 | pub const JOB_SOCKET_DIR: &str = "/run/g1t-docker"; | |
| 44 | /// The Docker CLI in the runner's image, lent to a job read-only. | |
| 45 | pub const DOCKER_CLI: &str = "/usr/local/bin/docker"; | |
| 46 | /// The image a job's engine runs. | |
| 47 | pub const DEFAULT_JOB_ENGINE_IMAGE: &str = "docker:28-dind"; | |
| 48 | ||
| 49 | const LOG: &str = "/run/g1t-runner/dockerd.log"; | |
| 50 | /// From moby's `hack/dind`, as crate::docker::engine does it. | |
| 51 | const 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. | |
| 56 | static HOST: OnceLock<String> = OnceLock::new(); | |
| 57 | /// The runner's engine, while it runs. | |
| 58 | static ENGINE: Mutex<Option<Child>> = Mutex::new(None); | |
| 59 | ||
| 60 | /// Whether the runner uses an engine of its own. | |
| 61 | pub fn in_use() -> bool { | |
| 62 | HOST.get().is_some() | |
| 63 | } | |
| 64 | ||
| 65 | /// `docker`, pointed at the runner's own engine when it has one. | |
| 66 | pub 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. | |
| 77 | pub 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. | |
| 94 | fn 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 | ||
| 103 | fn 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. | |
| 111 | pub 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. | |
| 165 | pub 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 | ||
| 180 | fn 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. | |
| 194 | pub 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)] | |
| 206 | struct 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. | |
| 219 | fn 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 | ||
| 234 | fn 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. | |
| 252 | fn 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 | ||
| 277 | impl 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)] | |
| 344 | mod 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.