| 1 | //! Running what g1t hands the runner: a workflow job or agent work, with |
| 2 | //! the same harness g1t's sandboxes run (this program, in another mode). |
| 3 | //! |
| 4 | //! - **In Docker** (the default): one container per job, removed when it |
| 5 | //! ends, from the job's `container:` image or the runner's `--image`. A |
| 6 | //! Linux build of the harness is mounted into it read-only; that and the |
| 7 | //! job's own work volume, removed after, are its only mounts. It runs |
| 8 | //! with every capability dropped but the few a build needs, no new |
| 9 | //! privileges, a process, memory and CPU limit, and a network of its |
| 10 | //! own. With nested-container isolation (sandbox.rs), all of this is on |
| 11 | //! the runner's own Docker Engine, inside the runner's container, and a |
| 12 | //! job that uses Docker gets an engine of its own (engine.rs). |
| 13 | //! - **Directly** (`--no-docker`, deprecated): the harness runs as a child |
| 14 | //! process, in a fresh folder under the runner's work folder that is |
| 15 | //! removed after. Unsandboxed: the job's log says so, and a public |
| 16 | //! repository's work is refused. |
| 17 | //! |
| 18 | //! The runner's own credential never reaches either: a job only ever has |
| 19 | //! its own short-lived token, as it would in g1t's sandbox. Credentials are |
| 20 | //! passed by name (`-e NAME`), never on the command line. |
| 21 | |
| 22 | use std::collections::BTreeMap; |
| 23 | use std::path::{Path, PathBuf}; |
| 24 | use std::process::{Child, Command, Stdio}; |
| 25 | use std::time::{Duration, Instant}; |
| 26 | |
| 27 | use anyhow::{Context, Result, bail}; |
| 28 | |
| 29 | use super::api::Assignment; |
| 30 | use super::config::Config; |
| 31 | use super::sandbox::{self, Isolation}; |
| 32 | use super::{log, update}; |
| 33 | |
| 34 | /// The image workflow jobs run in when they name none: Debian with Node, |
| 35 | /// git and Python, as g1t's sandbox has. |
| 36 | pub const DEFAULT_IMAGE: &str = "node:24-bookworm"; |
| 37 | /// The most processes in a job's container, unless the runner says. |
| 38 | pub const DEFAULT_PIDS_LIMIT: u32 = 4096; |
| 39 | |
| 40 | /// The capabilities a job's container keeps; Docker's others, and |
| 41 | /// everything else, are dropped. A workflow job runs as its image's user, |
| 42 | /// root for most CI images, and these are what installing packages and |
| 43 | /// building need: |
| 44 | /// |
| 45 | /// - `CHOWN`, `FOWNER`, `FSETID`, `DAC_OVERRIDE`: a package manager (apt, |
| 46 | /// npm as root) writing files it then hands to another owner, keeping |
| 47 | /// setgid bits, and root writing files it does not own. |
| 48 | /// - `SETUID`, `SETGID`: apt and others drop to their own user (`_apt`) to |
| 49 | /// download; with no new privileges, nothing can gain a user back. |
| 50 | /// - `KILL`: a step stopping processes another user started. |
| 51 | /// - `NET_BIND_SERVICE`: a test server on port 80 or 443. |
| 52 | /// |
| 53 | /// Not kept, among others: `NET_RAW` (raw sockets, `ping`), `MKNOD`, |
| 54 | /// `SYS_CHROOT`, `SETFCAP`, `AUDIT_WRITE`, and every `SYS_*` and `NET_ADMIN`. |
| 55 | pub const JOB_CAPS: &[&str] = &["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "SETUID", "SETGID", "KILL", "NET_BIND_SERVICE"]; |
| 56 | |
| 57 | /// Agent work's image runs as `node` and uses `sudo` once, to put its |
| 58 | /// guardrails' policy where the agent cannot change it, and then removes |
| 59 | /// its own sudo (guard.rs). sudo needs a setuid program to work and |
| 60 | /// `AUDIT_WRITE` to log, so agent work keeps those and has no |
| 61 | /// `no-new-privileges`; it still has every other limit. |
| 62 | pub const AGENT_EXTRA_CAPS: &[&str] = &["AUDIT_WRITE"]; |
| 63 | |
| 64 | /// Work in progress. |
| 65 | pub struct Running { |
| 66 | pub id: String, |
| 67 | pub kind: String, |
| 68 | pub name: String, |
| 69 | pub repo: String, |
| 70 | child: Child, |
| 71 | container: Option<String>, |
| 72 | /// The job's network and work volume, removed after. |
| 73 | network: Option<String>, |
| 74 | volume: Option<String>, |
| 75 | /// The job's own Docker Engine, when it may have one. |
| 76 | engine: Option<JobEngine>, |
| 77 | folder: Option<PathBuf>, |
| 78 | /// The job's own VM, for a job native to this OS (native.rs); `child` |
| 79 | /// is then `wsb exec`, whose output carries the job's exit code. |
| 80 | native: Option<WsbJob>, |
| 81 | /// How it ended, once read: a native job's code is read once from |
| 82 | /// `wsb exec`'s output. |
| 83 | exit: Option<i32>, |
| 84 | /// Past its time limit and a grace for g1t to say so: stopped here. |
| 85 | deadline: Instant, |
| 86 | } |
| 87 | |
| 88 | use super::native::{self, WsbJob}; |
| 89 | |
| 90 | #[cfg(unix)] |
| 91 | use super::engine::JobEngine; |
| 92 | |
| 93 | /// No job has an engine of its own off Unix. |
| 94 | #[cfg(not(unix))] |
| 95 | pub struct JobEngine; |
| 96 | |
| 97 | #[cfg(not(unix))] |
| 98 | impl JobEngine { |
| 99 | fn remove(&self) {} |
| 100 | fn job_args(&self) -> Vec<String> { |
| 101 | Vec::new() |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | /// `docker`, at the runner's own engine when it has one. |
| 106 | fn docker() -> Command { |
| 107 | #[cfg(unix)] |
| 108 | return super::engine::docker(); |
| 109 | #[cfg(not(unix))] |
| 110 | Command::new("docker") |
| 111 | } |
| 112 | |
| 113 | fn quietly(command: &mut Command) { |
| 114 | let _ = command.stdout(Stdio::null()).stderr(Stdio::null()).status(); |
| 115 | } |
| 116 | |
| 117 | impl Running { |
| 118 | pub fn overdue(&self) -> bool { |
| 119 | Instant::now() > self.deadline |
| 120 | } |
| 121 | |
| 122 | /// Its exit code once it has ended. |
| 123 | pub fn ended(&mut self) -> Option<i32> { |
| 124 | if self.exit.is_some() { |
| 125 | return self.exit; |
| 126 | } |
| 127 | self.exit = match self.child.try_wait() { |
| 128 | Ok(Some(_)) if self.native.is_some() => { |
| 129 | use std::io::Read; |
| 130 | let mut text = String::new(); |
| 131 | if let Some(out) = self.child.stdout.as_mut() { |
| 132 | let _ = out.read_to_string(&mut text); |
| 133 | } |
| 134 | Some(native::parse_exit_code(&text).unwrap_or(1)) |
| 135 | } |
| 136 | Ok(Some(status)) => Some(status.code().unwrap_or(1)), |
| 137 | Ok(None) => None, |
| 138 | Err(_) => Some(1), |
| 139 | }; |
| 140 | self.exit |
| 141 | } |
| 142 | |
| 143 | /// Stops it: the container, then the process and everything it started. |
| 144 | pub fn stop(&mut self) { |
| 145 | if let Some(container) = &self.container { |
| 146 | quietly(docker().args(["kill", container])); |
| 147 | } |
| 148 | #[cfg(unix)] |
| 149 | if let Some(engine) = &self.engine { |
| 150 | quietly(docker().args(["kill", &engine.container])); |
| 151 | } |
| 152 | if let Some(job) = &self.native { |
| 153 | job.stop(); |
| 154 | } |
| 155 | let pid = self.child.id().to_string(); |
| 156 | if cfg!(windows) { |
| 157 | quietly(Command::new("taskkill").args(["/T", "/F", "/PID", &pid])); |
| 158 | } else { |
| 159 | // Its own process group (see `directly`): the steps' processes too. |
| 160 | quietly(Command::new("kill").args(["-TERM", &format!("-{pid}")])); |
| 161 | } |
| 162 | let _ = self.child.kill(); |
| 163 | let _ = self.child.wait(); |
| 164 | } |
| 165 | |
| 166 | /// Removes what it left: its container, its engine and that engine's |
| 167 | /// data, its network, its work volume, its folder. |
| 168 | pub fn clean(&mut self) { |
| 169 | if let Some(container) = &self.container { |
| 170 | quietly(docker().args(["rm", "-f", "-v", container])); |
| 171 | } |
| 172 | if let Some(engine) = self.engine.take() { |
| 173 | engine.remove(); |
| 174 | } |
| 175 | if let Some(network) = &self.network { |
| 176 | quietly(docker().args(["network", "rm", network])); |
| 177 | } |
| 178 | if let Some(volume) = &self.volume { |
| 179 | quietly(docker().args(["volume", "rm", "-f", volume])); |
| 180 | } |
| 181 | if let Some(folder) = &self.folder { |
| 182 | let _ = std::fs::remove_dir_all(folder); |
| 183 | } |
| 184 | if let Some(job) = self.native.take() { |
| 185 | job.clean(); |
| 186 | } |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | /// A name Docker takes, from a job's id. |
| 191 | fn container_name(id: &str) -> String { |
| 192 | let clean: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '-' }).collect(); |
| 193 | format!("g1t-{clean}") |
| 194 | } |
| 195 | |
| 196 | /// The environment the harness gets for this work. |
| 197 | fn harness_env(config: &Config, work: &Assignment) -> Result<BTreeMap<String, String>> { |
| 198 | let mut env = BTreeMap::new(); |
| 199 | match work.kind.as_str() { |
| 200 | "workflow" => { |
| 201 | let Some(token) = &work.token else { bail!("the job came without its token") }; |
| 202 | env.insert("MODE".into(), "actions".into()); |
| 203 | env.insert("G1T_API".into(), config.api.clone()); |
| 204 | env.insert("ACTIONS_JOB".into(), work.id.clone()); |
| 205 | env.insert("ACTIONS_TOKEN".into(), token.clone()); |
| 206 | } |
| 207 | "agent" => { |
| 208 | for (name, value) in work.env.clone().unwrap_or_default() { |
| 209 | let value = match value { |
| 210 | serde_json::Value::String(text) => text, |
| 211 | other => other.to_string(), |
| 212 | }; |
| 213 | env.insert(name, value); |
| 214 | } |
| 215 | if !env.contains_key("MODE") && !env.contains_key("PROMPT") { |
| 216 | bail!("the agent work came without its environment"); |
| 217 | } |
| 218 | // Where its API calls go: this installation's. |
| 219 | env.insert("G1T_API".into(), config.api.clone()); |
| 220 | } |
| 221 | other => bail!("this runner does not know work of kind {other}; update it"), |
| 222 | } |
| 223 | // The machine is the workspace's own: no mining watch. |
| 224 | env.insert("G1T_ABUSE".into(), "off".into()); |
| 225 | Ok(env) |
| 226 | } |
| 227 | |
| 228 | /// Starts work. The harness reports a workflow job's steps and log itself; |
| 229 | /// the runner tells g1t how the process ended. |
| 230 | pub fn start(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment) -> Result<Running> { |
| 231 | let env = harness_env(config, work)?; |
| 232 | match isolation { |
| 233 | Isolation::Host => { |
| 234 | refuse_public_on_host(work)?; |
| 235 | directly(config, work, env) |
| 236 | } |
| 237 | Isolation::Vm => in_native_vm(config, folder, work, env), |
| 238 | _ => in_docker(config, isolation, folder, work, env), |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | /// A memory size as `--memory` takes it (`8g`, `4096m`), in MiB. |
| 243 | pub fn mib(text: &str) -> Option<u64> { |
| 244 | let text = text.trim().to_ascii_lowercase(); |
| 245 | let (number, unit) = text.split_at(text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len())); |
| 246 | let number: u64 = number.parse().ok()?; |
| 247 | match unit.trim_end_matches('b').trim_end_matches('i') { |
| 248 | "" | "m" => Some(number), |
| 249 | "g" => Some(number * 1024), |
| 250 | "k" => Some(number / 1024), |
| 251 | _ => None, |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | /// A job native to this OS, in a throwaway VM of its own (native.rs): |
| 256 | /// Windows Sandbox on Windows. Agent work needs g1t's Linux sandbox |
| 257 | /// layout and runs on a Linux runner. |
| 258 | fn in_native_vm(config: &Config, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> { |
| 259 | if work.kind != "workflow" { |
| 260 | bail!("agent work runs on Linux runners: this one runs jobs native to {}", std::env::consts::OS); |
| 261 | } |
| 262 | if !cfg!(windows) { |
| 263 | bail!("this runner has no sandbox for jobs native to {}", std::env::consts::OS); |
| 264 | } |
| 265 | let memory = config.memory.as_deref().and_then(mib).unwrap_or(4096); |
| 266 | let drop = folder.join("native").join(container_name(&work.id)); |
| 267 | let job = WsbJob::start(drop, &env, memory)?; |
| 268 | let child = match job.exec().spawn() { |
| 269 | Ok(child) => child, |
| 270 | Err(error) => { |
| 271 | job.clean(); |
| 272 | return Err(error).context("could not run wsb exec"); |
| 273 | } |
| 274 | }; |
| 275 | log(&format!( |
| 276 | "Running {} {} in a Windows Sandbox of its own ({memory} MiB): no folder of this machine's but its read-only harness, discarded after", |
| 277 | work.kind, work.name |
| 278 | )); |
| 279 | Ok(Running { |
| 280 | id: work.id.clone(), |
| 281 | repo: work.repo.clone(), |
| 282 | kind: work.kind.clone(), |
| 283 | name: work.name.clone(), |
| 284 | child, |
| 285 | container: None, |
| 286 | network: None, |
| 287 | volume: None, |
| 288 | engine: None, |
| 289 | folder: None, |
| 290 | native: Some(job), |
| 291 | exit: None, |
| 292 | deadline: deadline(work), |
| 293 | }) |
| 294 | } |
| 295 | |
| 296 | /// Host mode does not take a public repository's work: anyone can propose |
| 297 | /// code to one, and here it would run on the machine itself. |
| 298 | fn refuse_public_on_host(work: &Assignment) -> Result<()> { |
| 299 | match work.visibility.as_deref() { |
| 300 | Some("public") => bail!("{}", sandbox::host_mode_refuses(&work.repo)), |
| 301 | Some(_) => Ok(()), |
| 302 | None => { |
| 303 | log(&format!("g1t did not say whether {} is public (an older g1t), so it runs; host mode refuses public repositories' work once it does.", work.repo)); |
| 304 | Ok(()) |
| 305 | } |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | /// Each job container's limits. |
| 310 | #[derive(Debug, Clone, PartialEq)] |
| 311 | pub struct Limits { |
| 312 | pub memory: String, |
| 313 | pub cpus: String, |
| 314 | pub pids: u32, |
| 315 | } |
| 316 | |
| 317 | /// Three quarters of `MemTotal` in `/proc/meminfo`, in MiB, which under |
| 318 | /// Sysbox is the runner container's own memory. |
| 319 | fn default_memory(meminfo: &str) -> Option<String> { |
| 320 | let kb: u64 = meminfo.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; |
| 321 | let mib = kb / 1024 * 3 / 4; |
| 322 | (mib >= 256).then(|| format!("{mib}m")) |
| 323 | } |
| 324 | |
| 325 | pub fn limits(config: &Config) -> Limits { |
| 326 | let memory = config |
| 327 | .memory |
| 328 | .clone() |
| 329 | .or_else(|| default_memory(&std::fs::read_to_string("/proc/meminfo").unwrap_or_default())) |
| 330 | .unwrap_or_else(|| "4g".into()); |
| 331 | let cpus = config.cpus.clone().unwrap_or_else(|| std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2).to_string()); |
| 332 | Limits { memory, cpus, pids: config.pids_limit.unwrap_or(DEFAULT_PIDS_LIMIT) } |
| 333 | } |
| 334 | |
| 335 | /// Everything a job's `docker run` is made from. |
| 336 | pub struct Plan<'a> { |
| 337 | pub kind: &'a str, |
| 338 | pub name: &'a str, |
| 339 | pub runner: &'a str, |
| 340 | pub image: &'a str, |
| 341 | pub harness: &'a Path, |
| 342 | pub network: &'a str, |
| 343 | /// The job's work volume, for a workflow job's `/home/runner`. |
| 344 | pub volume: Option<&'a str>, |
| 345 | pub limits: &'a Limits, |
| 346 | /// The names of the harness's variables, whose values come from the |
| 347 | /// runner's process, never its command line. |
| 348 | pub env_names: Vec<&'a str>, |
| 349 | /// What the job's own engine adds (`JobEngine::job_args`). |
| 350 | pub engine_args: Vec<String>, |
| 351 | } |
| 352 | |
| 353 | /// The arguments of `docker` that start a job's container. |
| 354 | pub fn run_args(plan: &Plan) -> Vec<String> { |
| 355 | let mut args: Vec<String> = Vec::new(); |
| 356 | fn add(args: &mut Vec<String>, items: &[&str]) { |
| 357 | args.extend(items.iter().map(|s| s.to_string())); |
| 358 | } |
| 359 | add(&mut args, &["run", "--rm", "--name", plan.name, "--label", &format!("sh.g1t.runner={}", plan.runner)]); |
| 360 | add(&mut args, &["--pull", "missing", "--init"]); |
| 361 | // The walls. |
| 362 | add(&mut args, &["--cap-drop", "ALL"]); |
| 363 | let mut caps: Vec<&str> = JOB_CAPS.to_vec(); |
| 364 | if plan.kind == "agent" { |
| 365 | caps.extend(AGENT_EXTRA_CAPS); |
| 366 | } else { |
| 367 | add(&mut args, &["--security-opt", "no-new-privileges"]); |
| 368 | } |
| 369 | for cap in caps { |
| 370 | add(&mut args, &["--cap-add", cap]); |
| 371 | } |
| 372 | let pids = plan.limits.pids.to_string(); |
| 373 | add(&mut args, &["--pids-limit", &pids, "--memory", &plan.limits.memory, "--memory-swap", &plan.limits.memory, "--cpus", &plan.limits.cpus]); |
| 374 | add(&mut args, &["--network", plan.network, "--ipc", "private"]); |
| 375 | // The job is in its `container:` image already: the harness inside |
| 376 | // does not start it again (actions/containers.rs). |
| 377 | add(&mut args, &["-e", "G1T_JOB_CONTAINER=1"]); |
| 378 | for name in &plan.env_names { |
| 379 | add(&mut args, &["-e", name]); |
| 380 | } |
| 381 | // Its only mounts: the harness, read-only, and its own volume. |
| 382 | add(&mut args, &["-v", &format!("{}:/opt/g1t/g1t-runner:ro", plan.harness.display())]); |
| 383 | if let Some(volume) = plan.volume { |
| 384 | add(&mut args, &["-v", &format!("{volume}:/home/runner")]); |
| 385 | } |
| 386 | args.extend(plan.engine_args.iter().cloned()); |
| 387 | add(&mut args, &["--entrypoint", "/opt/g1t/g1t-runner", plan.image]); |
| 388 | args |
| 389 | } |
| 390 | |
| 391 | fn in_docker(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> { |
| 392 | let image = match work.kind.as_str() { |
| 393 | "agent" => match config.agent_image.clone().or_else(|| update::agent_image(config)) { |
| 394 | Some(image) => image, |
| 395 | None => bail!( |
| 396 | "agent work needs an image with git, Node and the agent's CLI: register this runner with --agent-image IMAGE" |
| 397 | ), |
| 398 | }, |
| 399 | _ => work.image.clone().or_else(|| config.image.clone()).unwrap_or_else(|| DEFAULT_IMAGE.to_owned()), |
| 400 | }; |
| 401 | #[cfg(unix)] |
| 402 | if super::engine::in_use() { |
| 403 | super::engine::ensure_running()?; |
| 404 | } |
| 405 | let harness = update::linux_harness(config, folder)?; |
| 406 | let name = container_name(&work.id); |
| 407 | // What an earlier run of this job may have left. |
| 408 | quietly(docker().args(["rm", "-f", "-v", &name])); |
| 409 | quietly(docker().args(["network", "rm", &name])); |
| 410 | let output = docker() |
| 411 | .args(["network", "create", "--label", &format!("sh.g1t.runner={}", config.runner), &name]) |
| 412 | .output() |
| 413 | .context("could not run docker: is Docker installed and running?")?; |
| 414 | if !output.status.success() { |
| 415 | bail!("could not make the job's network: {}", String::from_utf8_lossy(&output.stderr).trim()); |
| 416 | } |
| 417 | let volume = (work.kind == "workflow").then(|| format!("{name}-work")); |
| 418 | if let Some(volume) = &volume { |
| 419 | quietly(docker().args(["volume", "rm", "-f", volume])); |
| 420 | quietly(docker().args(["volume", "create", "--label", &format!("sh.g1t.runner={}", config.runner), volume])); |
| 421 | } |
| 422 | // A job's own Docker, only inside the runner's own sandbox: on a |
| 423 | // shared engine it would be the machine's. |
| 424 | let engine: Option<JobEngine> = job_engine(config, isolation, &name); |
| 425 | let limits = limits(config); |
| 426 | let plan = Plan { |
| 427 | kind: &work.kind, |
| 428 | name: &name, |
| 429 | runner: &config.runner, |
| 430 | image: &image, |
| 431 | harness: &harness, |
| 432 | network: &name, |
| 433 | volume: volume.as_deref(), |
| 434 | limits: &limits, |
| 435 | env_names: env.keys().map(String::as_str).collect(), |
| 436 | engine_args: engine.as_ref().map(JobEngine::job_args).unwrap_or_default(), |
| 437 | }; |
| 438 | let mut command = docker(); |
| 439 | command.args(run_args(&plan)); |
| 440 | command.envs(&env); |
| 441 | log(&format!( |
| 442 | "Running {} {} in {image}: its own container and network, capabilities dropped, {} memory, {} CPUs, {} processes{}", |
| 443 | work.kind, |
| 444 | work.name, |
| 445 | limits.memory, |
| 446 | limits.cpus, |
| 447 | limits.pids, |
| 448 | if engine.is_some() { ", Docker of its own when it uses it" } else { "" } |
| 449 | )); |
| 450 | let child = match command.spawn() { |
| 451 | Ok(child) => child, |
| 452 | Err(error) => { |
| 453 | if let Some(engine) = &engine { |
| 454 | engine.remove(); |
| 455 | } |
| 456 | quietly(docker().args(["network", "rm", &name])); |
| 457 | return Err(error).context("could not run docker: is Docker installed and running?"); |
| 458 | } |
| 459 | }; |
| 460 | Ok(Running { |
| 461 | id: work.id.clone(), |
| 462 | repo: work.repo.clone(), |
| 463 | kind: work.kind.clone(), |
| 464 | name: work.name.clone(), |
| 465 | child, |
| 466 | container: Some(name.clone()), |
| 467 | network: Some(name), |
| 468 | volume, |
| 469 | engine, |
| 470 | folder: None, |
| 471 | native: None, |
| 472 | exit: None, |
| 473 | deadline: deadline(work), |
| 474 | }) |
| 475 | } |
| 476 | |
| 477 | #[cfg(unix)] |
| 478 | fn job_engine(config: &Config, isolation: Isolation, name: &str) -> Option<JobEngine> { |
| 479 | if isolation != Isolation::NestedContainer || !config.job_docker { |
| 480 | return None; |
| 481 | } |
| 482 | let image = config.job_docker_image.as_deref().unwrap_or(super::engine::DEFAULT_JOB_ENGINE_IMAGE); |
| 483 | match JobEngine::prepare(name, &config.runner, image) { |
| 484 | Ok(engine) => Some(engine), |
| 485 | Err(error) => { |
| 486 | log(&format!("This job has no Docker of its own: {error:#}")); |
| 487 | None |
| 488 | } |
| 489 | } |
| 490 | } |
| 491 | |
| 492 | #[cfg(not(unix))] |
| 493 | fn job_engine(_config: &Config, _isolation: Isolation, _name: &str) -> Option<JobEngine> { |
| 494 | None |
| 495 | } |
| 496 | |
| 497 | fn directly(config: &Config, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> { |
| 498 | let me = std::env::current_exe().context("could not find this program to run the job")?; |
| 499 | let mut command = Command::new(me); |
| 500 | let mut folder = None; |
| 501 | if work.kind == "workflow" { |
| 502 | // GitHub's layout, in a folder of its own. |
| 503 | let root = config.work_dir.join(container_name(&work.id)); |
| 504 | let _ = std::fs::remove_dir_all(&root); |
| 505 | std::fs::create_dir_all(&root).with_context(|| format!("could not make {}", root.display()))?; |
| 506 | command.env("G1T_RUNNER_ROOT", &root); |
| 507 | folder = Some(root); |
| 508 | } else if !cfg!(target_os = "linux") || std::fs::create_dir_all("/work").is_err() { |
| 509 | // Agent work expects g1t's sandbox layout (/work), which only a |
| 510 | // Linux machine (or a container) set aside for it has. |
| 511 | bail!("agent work runs in Docker, or directly on Linux where /work can be written; this runner can do neither"); |
| 512 | } |
| 513 | // Its own variables, less anything of the runner's. |
| 514 | for (name, _) in std::env::vars_os() { |
| 515 | if name.to_string_lossy().starts_with("G1T_RUNNER_") && name != "G1T_RUNNER_ROOT" { |
| 516 | command.env_remove(&name); |
| 517 | } |
| 518 | } |
| 519 | command.envs(&env); |
| 520 | // The job's log says it ran unsandboxed (actions/mod.rs). |
| 521 | command.env(JOB_NOTICE, sandbox::HOST_MODE_IN_JOB); |
| 522 | // A group of its own, so stopping it stops what its steps started. |
| 523 | #[cfg(unix)] |
| 524 | std::os::unix::process::CommandExt::process_group(&mut command, 0); |
| 525 | log(&format!("Running {} {} on this machine, unsandboxed (host mode, deprecated)", work.kind, work.name)); |
| 526 | let child = command.spawn().context("could not start the job's process")?; |
| 527 | Ok(Running { |
| 528 | id: work.id.clone(), |
| 529 | repo: work.repo.clone(), |
| 530 | kind: work.kind.clone(), |
| 531 | name: work.name.clone(), |
| 532 | child, |
| 533 | container: None, |
| 534 | network: None, |
| 535 | volume: None, |
| 536 | engine: None, |
| 537 | folder, |
| 538 | native: None, |
| 539 | exit: None, |
| 540 | deadline: deadline(work), |
| 541 | }) |
| 542 | } |
| 543 | |
| 544 | /// A warning the harness puts at the top of the job's log. |
| 545 | pub const JOB_NOTICE: &str = "G1T_JOB_NOTICE"; |
| 546 | |
| 547 | fn deadline(work: &Assignment) -> Instant { |
| 548 | Instant::now() + Duration::from_secs(u64::from(work.timeout_minutes.max(1)) * 60 + 10 * 60) |
| 549 | } |
| 550 | |
| 551 | /// Whether Docker answers. |
| 552 | pub fn docker_ready() -> bool { |
| 553 | docker() |
| 554 | .args(["version", "--format", "{{.Server.Version}}"]) |
| 555 | .stdout(Stdio::null()) |
| 556 | .stderr(Stdio::null()) |
| 557 | .status() |
| 558 | .is_ok_and(|status| status.success()) |
| 559 | } |
| 560 | |
| 561 | /// Removes what jobs of this runner's left behind, if it stopped while one ran. |
| 562 | pub fn sweep(config: &Config) { |
| 563 | let label = format!("label=sh.g1t.runner={}", config.runner); |
| 564 | for (list, remove) in [ |
| 565 | (vec!["ps", "-aq", "--filter", &label], vec!["rm", "-f", "-v"]), |
| 566 | (vec!["network", "ls", "-q", "--filter", &label], vec!["network", "rm"]), |
| 567 | (vec!["volume", "ls", "-q", "--filter", &label], vec!["volume", "rm", "-f"]), |
| 568 | ] { |
| 569 | let Ok(output) = docker().args(&list).output() else { return }; |
| 570 | let ids: Vec<String> = String::from_utf8_lossy(&output.stdout).split_whitespace().map(str::to_owned).collect(); |
| 571 | if !ids.is_empty() { |
| 572 | quietly(docker().args(&remove).args(&ids)); |
| 573 | } |
| 574 | } |
| 575 | } |
| 576 | |
| 577 | #[cfg(test)] |
| 578 | mod tests { |
| 579 | use super::*; |
| 580 | use serde_json::json; |
| 581 | |
| 582 | fn config() -> Config { |
| 583 | Config { |
| 584 | url: "https://g1t.sh".into(), |
| 585 | api: "https://api.g1t.sh".into(), |
| 586 | runner: "rnr_1".into(), |
| 587 | name: "a".into(), |
| 588 | credential: "g1tr_secret".into(), |
| 589 | workspace: "acme".into(), |
| 590 | repo: None, |
| 591 | group: None, |
| 592 | labels: vec![], |
| 593 | ephemeral: false, |
| 594 | work_dir: PathBuf::from("/tmp/w"), |
| 595 | docker: true, |
| 596 | image: None, |
| 597 | agent_image: None, |
| 598 | harness: None, |
| 599 | auto_update: false, |
| 600 | memory: None, |
| 601 | cpus: None, |
| 602 | pids_limit: None, |
| 603 | job_docker: true, |
| 604 | job_docker_image: None, |
| 605 | native: false, |
| 606 | } |
| 607 | } |
| 608 | |
| 609 | fn assignment(kind: &str) -> Assignment { |
| 610 | serde_json::from_value(json!({ |
| 611 | "kind": kind, "id": "job_1", "name": "build", "repo": "acme/web", "timeout_minutes": 60, |
| 612 | "token": "jobtoken", "env": { "MODE": "checks", "G1T_TOKEN": "g1t_run", "G1T_API": "https://elsewhere" } |
| 613 | })) |
| 614 | .unwrap() |
| 615 | } |
| 616 | |
| 617 | #[test] |
| 618 | fn a_workflow_job_gets_its_own_token_and_never_the_runners() { |
| 619 | let env = harness_env(&config(), &assignment("workflow")).unwrap(); |
| 620 | assert_eq!(env["MODE"], "actions"); |
| 621 | assert_eq!(env["ACTIONS_JOB"], "job_1"); |
| 622 | assert_eq!(env["ACTIONS_TOKEN"], "jobtoken"); |
| 623 | assert_eq!(env["G1T_API"], "https://api.g1t.sh"); |
| 624 | assert!(env.values().all(|value| !value.contains("g1tr_"))); |
| 625 | } |
| 626 | |
| 627 | #[test] |
| 628 | fn agent_work_gets_the_sandboxs_environment_and_this_api() { |
| 629 | let env = harness_env(&config(), &assignment("agent")).unwrap(); |
| 630 | assert_eq!(env["MODE"], "checks"); |
| 631 | assert_eq!(env["G1T_TOKEN"], "g1t_run"); |
| 632 | assert_eq!(env["G1T_API"], "https://api.g1t.sh"); |
| 633 | assert_eq!(env["G1T_ABUSE"], "off"); |
| 634 | } |
| 635 | |
| 636 | #[test] |
| 637 | fn unknown_work_is_refused() { |
| 638 | assert!(harness_env(&config(), &assignment("deploy")).is_err()); |
| 639 | } |
| 640 | |
| 641 | #[test] |
| 642 | fn container_names_are_dockers() { |
| 643 | assert_eq!(container_name("job_01ab"), "g1t-job_01ab"); |
| 644 | assert_eq!(container_name("a/b c"), "g1t-a-b-c"); |
| 645 | } |
| 646 | |
| 647 | fn args_for(kind: &str, engine_args: Vec<String>) -> (Vec<String>, BTreeMap<String, String>) { |
| 648 | let config = config(); |
| 649 | let env = harness_env(&config, &assignment(kind)).unwrap(); |
| 650 | let limits = Limits { memory: "6144m".into(), cpus: "4".into(), pids: 4096 }; |
| 651 | let harness = PathBuf::from("/usr/local/bin/g1t-runner"); |
| 652 | let plan = Plan { |
| 653 | kind, |
| 654 | name: "g1t-job_1", |
| 655 | runner: "rnr_1", |
| 656 | image: DEFAULT_IMAGE, |
| 657 | harness: &harness, |
| 658 | network: "g1t-job_1", |
| 659 | volume: (kind == "workflow").then_some("g1t-job_1-work"), |
| 660 | limits: &limits, |
| 661 | env_names: env.keys().map(String::as_str).collect(), |
| 662 | engine_args, |
| 663 | }; |
| 664 | (run_args(&plan), env.clone()) |
| 665 | } |
| 666 | |
| 667 | fn value_of<'a>(args: &'a [String], flag: &str) -> Vec<&'a str> { |
| 668 | args.windows(2).filter(|w| w[0] == flag).map(|w| w[1].as_str()).collect() |
| 669 | } |
| 670 | |
| 671 | #[test] |
| 672 | fn a_jobs_container_is_walled_in() { |
| 673 | let (args, _) = args_for("workflow", Vec::new()); |
| 674 | assert_eq!(args[0], "run"); |
| 675 | assert!(args.contains(&"--rm".to_owned())); |
| 676 | assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]); |
| 677 | assert_eq!(value_of(&args, "--cap-add"), JOB_CAPS.to_vec()); |
| 678 | assert_eq!(value_of(&args, "--security-opt"), vec!["no-new-privileges"]); |
| 679 | assert_eq!(value_of(&args, "--pids-limit"), vec!["4096"]); |
| 680 | assert_eq!(value_of(&args, "--memory"), vec!["6144m"]); |
| 681 | assert_eq!(value_of(&args, "--memory-swap"), vec!["6144m"]); |
| 682 | assert_eq!(value_of(&args, "--cpus"), vec!["4"]); |
| 683 | assert_eq!(value_of(&args, "--network"), vec!["g1t-job_1"]); |
| 684 | for forbidden in ["--privileged", "--pid", "--userns", "--device", "host"] { |
| 685 | assert!(!args.iter().any(|a| a == forbidden), "{forbidden} in {args:?}"); |
| 686 | } |
| 687 | assert!(!JOB_CAPS.iter().any(|c| c.starts_with("SYS_") || *c == "NET_ADMIN" || *c == "NET_RAW")); |
| 688 | } |
| 689 | |
| 690 | #[test] |
| 691 | fn a_jobs_only_mounts_are_the_harness_and_its_own_volume() { |
| 692 | let (args, _) = args_for("workflow", Vec::new()); |
| 693 | let mounts = value_of(&args, "-v"); |
| 694 | assert_eq!(mounts, vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro", "g1t-job_1-work:/home/runner"]); |
| 695 | assert!(!args.iter().any(|a| a.contains("docker.sock")), "no engine socket: {args:?}"); |
| 696 | assert!(!args.iter().any(|a| a == "--mount")); |
| 697 | } |
| 698 | |
| 699 | #[test] |
| 700 | fn credentials_go_by_name_and_the_runners_never() { |
| 701 | let (args, env) = args_for("workflow", Vec::new()); |
| 702 | let passed = value_of(&args, "-e"); |
| 703 | assert!(passed.contains(&"ACTIONS_TOKEN")); |
| 704 | // Never a value on the command line, and never the runner's credential. |
| 705 | assert!(!args.iter().any(|a| a.contains("jobtoken"))); |
| 706 | assert!(!args.iter().any(|a| a.contains("g1tr_"))); |
| 707 | assert!(env.values().all(|v| !v.contains("g1tr_secret"))); |
| 708 | assert!(!env.contains_key("G1T_RUNNER_TOKEN")); |
| 709 | } |
| 710 | |
| 711 | #[test] |
| 712 | fn agent_work_keeps_sudo_for_its_guardrails_and_every_other_wall() { |
| 713 | let (args, _) = args_for("agent", Vec::new()); |
| 714 | assert!(value_of(&args, "--security-opt").is_empty()); |
| 715 | assert!(value_of(&args, "--cap-add").contains(&"AUDIT_WRITE")); |
| 716 | assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]); |
| 717 | assert_eq!(value_of(&args, "-v"), vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro"]); |
| 718 | } |
| 719 | |
| 720 | #[test] |
| 721 | fn a_job_with_docker_gets_a_socket_of_its_own_not_the_runners() { |
| 722 | let engine_args = vec!["-v".into(), "/run/g1t-runner/jobs/g1t-job_1/proxy:/run/g1t-docker:ro".into(), "-e".into(), "DOCKER_HOST=unix:///run/g1t-docker/docker.sock".into()]; |
| 723 | let (args, _) = args_for("workflow", engine_args); |
| 724 | let mounts = value_of(&args, "-v"); |
| 725 | assert!(mounts.contains(&"/run/g1t-runner/jobs/g1t-job_1/proxy:/run/g1t-docker:ro")); |
| 726 | assert!(!mounts.iter().any(|m| m.starts_with("/var/run/docker.sock") || m.starts_with("/run/g1t-runner/docker.sock"))); |
| 727 | // The entrypoint and image stay last. |
| 728 | assert_eq!(&args[args.len() - 3..], ["--entrypoint", "/opt/g1t/g1t-runner", DEFAULT_IMAGE]); |
| 729 | } |
| 730 | |
| 731 | #[test] |
| 732 | fn limits_default_sensibly() { |
| 733 | assert_eq!(default_memory("MemTotal: 16384000 kB\nMemFree: 1 kB\n"), Some("12000m".into())); |
| 734 | assert_eq!(default_memory(""), None); |
| 735 | let mut config = config(); |
| 736 | config.memory = Some("2g".into()); |
| 737 | config.pids_limit = Some(100); |
| 738 | let limits = limits(&config); |
| 739 | assert_eq!(limits.memory, "2g"); |
| 740 | assert_eq!(limits.pids, 100); |
| 741 | assert!(limits.cpus.parse::<f64>().unwrap() >= 1.0); |
| 742 | } |
| 743 | |
| 744 | #[test] |
| 745 | fn host_mode_refuses_a_public_repositorys_work() { |
| 746 | let mut work = assignment("workflow"); |
| 747 | work.visibility = Some("public".into()); |
| 748 | let refused = refuse_public_on_host(&work).unwrap_err().to_string(); |
| 749 | assert!(refused.contains("acme/web is public"), "{refused}"); |
| 750 | work.visibility = Some("private".into()); |
| 751 | assert!(refuse_public_on_host(&work).is_ok()); |
| 752 | work.visibility = None; |
| 753 | assert!(refuse_public_on_host(&work).is_ok()); |
| 754 | // And refuses it before anything runs. |
| 755 | work.visibility = Some("public".into()); |
| 756 | assert!(start(&config(), Isolation::Host, Path::new("/tmp"), &work).is_err()); |
| 757 | } |
| 758 | } |