| 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 | child: Child, |
| 70 | container: Option<String>, |
| 71 | /// The job's network and work volume, removed after. |
| 72 | network: Option<String>, |
| 73 | volume: Option<String>, |
| 74 | /// The job's own Docker Engine, when it may have one. |
| 75 | engine: Option<JobEngine>, |
| 76 | folder: Option<PathBuf>, |
| 77 | /// Past its time limit and a grace for g1t to say so: stopped here. |
| 78 | deadline: Instant, |
| 79 | } |
| 80 | |
| 81 | #[cfg(unix)] |
| 82 | use super::engine::JobEngine; |
| 83 | |
| 84 | /// No job has an engine of its own off Unix. |
| 85 | #[cfg(not(unix))] |
| 86 | pub struct JobEngine; |
| 87 | |
| 88 | #[cfg(not(unix))] |
| 89 | impl JobEngine { |
| 90 | fn remove(&self) {} |
| 91 | fn job_args(&self) -> Vec<String> { |
| 92 | Vec::new() |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | /// `docker`, at the runner's own engine when it has one. |
| 97 | fn docker() -> Command { |
| 98 | #[cfg(unix)] |
| 99 | return super::engine::docker(); |
| 100 | #[cfg(not(unix))] |
| 101 | Command::new("docker") |
| 102 | } |
| 103 | |
| 104 | fn quietly(command: &mut Command) { |
| 105 | let _ = command.stdout(Stdio::null()).stderr(Stdio::null()).status(); |
| 106 | } |
| 107 | |
| 108 | impl Running { |
| 109 | pub fn overdue(&self) -> bool { |
| 110 | Instant::now() > self.deadline |
| 111 | } |
| 112 | |
| 113 | /// Its exit code once it has ended. |
| 114 | pub fn ended(&mut self) -> Option<i32> { |
| 115 | match self.child.try_wait() { |
| 116 | Ok(Some(status)) => Some(status.code().unwrap_or(1)), |
| 117 | Ok(None) => None, |
| 118 | Err(_) => Some(1), |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | /// Stops it: the container, then the process and everything it started. |
| 123 | pub fn stop(&mut self) { |
| 124 | if let Some(container) = &self.container { |
| 125 | quietly(docker().args(["kill", container])); |
| 126 | } |
| 127 | if let Some(engine) = &self.engine { |
| 128 | quietly(docker().args(["kill", &engine.container])); |
| 129 | } |
| 130 | let pid = self.child.id().to_string(); |
| 131 | if cfg!(windows) { |
| 132 | quietly(Command::new("taskkill").args(["/T", "/F", "/PID", &pid])); |
| 133 | } else { |
| 134 | // Its own process group (see `directly`): the steps' processes too. |
| 135 | quietly(Command::new("kill").args(["-TERM", &format!("-{pid}")])); |
| 136 | } |
| 137 | let _ = self.child.kill(); |
| 138 | let _ = self.child.wait(); |
| 139 | } |
| 140 | |
| 141 | /// Removes what it left: its container, its engine and that engine's |
| 142 | /// data, its network, its work volume, its folder. |
| 143 | pub fn clean(&mut self) { |
| 144 | if let Some(container) = &self.container { |
| 145 | quietly(docker().args(["rm", "-f", "-v", container])); |
| 146 | } |
| 147 | if let Some(engine) = self.engine.take() { |
| 148 | engine.remove(); |
| 149 | } |
| 150 | if let Some(network) = &self.network { |
| 151 | quietly(docker().args(["network", "rm", network])); |
| 152 | } |
| 153 | if let Some(volume) = &self.volume { |
| 154 | quietly(docker().args(["volume", "rm", "-f", volume])); |
| 155 | } |
| 156 | if let Some(folder) = &self.folder { |
| 157 | let _ = std::fs::remove_dir_all(folder); |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | /// A name Docker takes, from a job's id. |
| 163 | fn container_name(id: &str) -> String { |
| 164 | let clean: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '-' }).collect(); |
| 165 | format!("g1t-{clean}") |
| 166 | } |
| 167 | |
| 168 | /// The environment the harness gets for this work. |
| 169 | fn harness_env(config: &Config, work: &Assignment) -> Result<BTreeMap<String, String>> { |
| 170 | let mut env = BTreeMap::new(); |
| 171 | match work.kind.as_str() { |
| 172 | "workflow" => { |
| 173 | let Some(token) = &work.token else { bail!("the job came without its token") }; |
| 174 | env.insert("MODE".into(), "actions".into()); |
| 175 | env.insert("G1T_API".into(), config.api.clone()); |
| 176 | env.insert("ACTIONS_JOB".into(), work.id.clone()); |
| 177 | env.insert("ACTIONS_TOKEN".into(), token.clone()); |
| 178 | } |
| 179 | "agent" => { |
| 180 | for (name, value) in work.env.clone().unwrap_or_default() { |
| 181 | let value = match value { |
| 182 | serde_json::Value::String(text) => text, |
| 183 | other => other.to_string(), |
| 184 | }; |
| 185 | env.insert(name, value); |
| 186 | } |
| 187 | if !env.contains_key("MODE") && !env.contains_key("PROMPT") { |
| 188 | bail!("the agent work came without its environment"); |
| 189 | } |
| 190 | // Where its API calls go: this installation's. |
| 191 | env.insert("G1T_API".into(), config.api.clone()); |
| 192 | } |
| 193 | other => bail!("this runner does not know work of kind {other}; update it"), |
| 194 | } |
| 195 | // The machine is the workspace's own: no mining watch. |
| 196 | env.insert("G1T_ABUSE".into(), "off".into()); |
| 197 | Ok(env) |
| 198 | } |
| 199 | |
| 200 | /// Starts work. The harness reports a workflow job's steps and log itself; |
| 201 | /// the runner tells g1t how the process ended. |
| 202 | pub fn start(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment) -> Result<Running> { |
| 203 | let env = harness_env(config, work)?; |
| 204 | if isolation == Isolation::Host { |
| 205 | refuse_public_on_host(work)?; |
| 206 | return directly(config, work, env); |
| 207 | } |
| 208 | in_docker(config, isolation, folder, work, env) |
| 209 | } |
| 210 | |
| 211 | /// Host mode does not take a public repository's work: anyone can propose |
| 212 | /// code to one, and here it would run on the machine itself. |
| 213 | fn refuse_public_on_host(work: &Assignment) -> Result<()> { |
| 214 | match work.visibility.as_deref() { |
| 215 | Some("public") => bail!("{}", sandbox::host_mode_refuses(&work.repo)), |
| 216 | Some(_) => Ok(()), |
| 217 | None => { |
| 218 | 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)); |
| 219 | Ok(()) |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | /// Each job container's limits. |
| 225 | #[derive(Debug, Clone, PartialEq)] |
| 226 | pub struct Limits { |
| 227 | pub memory: String, |
| 228 | pub cpus: String, |
| 229 | pub pids: u32, |
| 230 | } |
| 231 | |
| 232 | /// Three quarters of `MemTotal` in `/proc/meminfo`, in MiB, which under |
| 233 | /// Sysbox is the runner container's own memory. |
| 234 | fn default_memory(meminfo: &str) -> Option<String> { |
| 235 | let kb: u64 = meminfo.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; |
| 236 | let mib = kb / 1024 * 3 / 4; |
| 237 | (mib >= 256).then(|| format!("{mib}m")) |
| 238 | } |
| 239 | |
| 240 | pub fn limits(config: &Config) -> Limits { |
| 241 | let memory = config |
| 242 | .memory |
| 243 | .clone() |
| 244 | .or_else(|| default_memory(&std::fs::read_to_string("/proc/meminfo").unwrap_or_default())) |
| 245 | .unwrap_or_else(|| "4g".into()); |
| 246 | let cpus = config.cpus.clone().unwrap_or_else(|| std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2).to_string()); |
| 247 | Limits { memory, cpus, pids: config.pids_limit.unwrap_or(DEFAULT_PIDS_LIMIT) } |
| 248 | } |
| 249 | |
| 250 | /// Everything a job's `docker run` is made from. |
| 251 | pub struct Plan<'a> { |
| 252 | pub kind: &'a str, |
| 253 | pub name: &'a str, |
| 254 | pub runner: &'a str, |
| 255 | pub image: &'a str, |
| 256 | pub harness: &'a Path, |
| 257 | pub network: &'a str, |
| 258 | /// The job's work volume, for a workflow job's `/home/runner`. |
| 259 | pub volume: Option<&'a str>, |
| 260 | pub limits: &'a Limits, |
| 261 | /// The names of the harness's variables, whose values come from the |
| 262 | /// runner's process, never its command line. |
| 263 | pub env_names: Vec<&'a str>, |
| 264 | /// What the job's own engine adds (`JobEngine::job_args`). |
| 265 | pub engine_args: Vec<String>, |
| 266 | } |
| 267 | |
| 268 | /// The arguments of `docker` that start a job's container. |
| 269 | pub fn run_args(plan: &Plan) -> Vec<String> { |
| 270 | let mut args: Vec<String> = Vec::new(); |
| 271 | fn add(args: &mut Vec<String>, items: &[&str]) { |
| 272 | args.extend(items.iter().map(|s| s.to_string())); |
| 273 | } |
| 274 | add(&mut args, &["run", "--rm", "--name", plan.name, "--label", &format!("sh.g1t.runner={}", plan.runner)]); |
| 275 | add(&mut args, &["--pull", "missing", "--init"]); |
| 276 | // The walls. |
| 277 | add(&mut args, &["--cap-drop", "ALL"]); |
| 278 | let mut caps: Vec<&str> = JOB_CAPS.to_vec(); |
| 279 | if plan.kind == "agent" { |
| 280 | caps.extend(AGENT_EXTRA_CAPS); |
| 281 | } else { |
| 282 | add(&mut args, &["--security-opt", "no-new-privileges"]); |
| 283 | } |
| 284 | for cap in caps { |
| 285 | add(&mut args, &["--cap-add", cap]); |
| 286 | } |
| 287 | let pids = plan.limits.pids.to_string(); |
| 288 | add(&mut args, &["--pids-limit", &pids, "--memory", &plan.limits.memory, "--memory-swap", &plan.limits.memory, "--cpus", &plan.limits.cpus]); |
| 289 | add(&mut args, &["--network", plan.network, "--ipc", "private"]); |
| 290 | // The job is in its `container:` image already: the harness inside |
| 291 | // does not start it again (actions/containers.rs). |
| 292 | add(&mut args, &["-e", "G1T_JOB_CONTAINER=1"]); |
| 293 | for name in &plan.env_names { |
| 294 | add(&mut args, &["-e", name]); |
| 295 | } |
| 296 | // Its only mounts: the harness, read-only, and its own volume. |
| 297 | add(&mut args, &["-v", &format!("{}:/opt/g1t/g1t-runner:ro", plan.harness.display())]); |
| 298 | if let Some(volume) = plan.volume { |
| 299 | add(&mut args, &["-v", &format!("{volume}:/home/runner")]); |
| 300 | } |
| 301 | args.extend(plan.engine_args.iter().cloned()); |
| 302 | add(&mut args, &["--entrypoint", "/opt/g1t/g1t-runner", plan.image]); |
| 303 | args |
| 304 | } |
| 305 | |
| 306 | fn in_docker(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> { |
| 307 | let image = match work.kind.as_str() { |
| 308 | "agent" => match config.agent_image.clone().or_else(|| update::agent_image(config)) { |
| 309 | Some(image) => image, |
| 310 | None => bail!( |
| 311 | "agent work needs an image with git, Node and the agent's CLI: register this runner with --agent-image IMAGE" |
| 312 | ), |
| 313 | }, |
| 314 | _ => work.image.clone().or_else(|| config.image.clone()).unwrap_or_else(|| DEFAULT_IMAGE.to_owned()), |
| 315 | }; |
| 316 | #[cfg(unix)] |
| 317 | if super::engine::in_use() { |
| 318 | super::engine::ensure_running()?; |
| 319 | } |
| 320 | let harness = update::linux_harness(config, folder)?; |
| 321 | let name = container_name(&work.id); |
| 322 | // What an earlier run of this job may have left. |
| 323 | quietly(docker().args(["rm", "-f", "-v", &name])); |
| 324 | quietly(docker().args(["network", "rm", &name])); |
| 325 | let output = docker() |
| 326 | .args(["network", "create", "--label", &format!("sh.g1t.runner={}", config.runner), &name]) |
| 327 | .output() |
| 328 | .context("could not run docker: is Docker installed and running?")?; |
| 329 | if !output.status.success() { |
| 330 | bail!("could not make the job's network: {}", String::from_utf8_lossy(&output.stderr).trim()); |
| 331 | } |
| 332 | let volume = (work.kind == "workflow").then(|| format!("{name}-work")); |
| 333 | if let Some(volume) = &volume { |
| 334 | quietly(docker().args(["volume", "rm", "-f", volume])); |
| 335 | quietly(docker().args(["volume", "create", "--label", &format!("sh.g1t.runner={}", config.runner), volume])); |
| 336 | } |
| 337 | // A job's own Docker, only inside the runner's own sandbox: on a |
| 338 | // shared engine it would be the machine's. |
| 339 | let engine: Option<JobEngine> = job_engine(config, isolation, &name); |
| 340 | let limits = limits(config); |
| 341 | let plan = Plan { |
| 342 | kind: &work.kind, |
| 343 | name: &name, |
| 344 | runner: &config.runner, |
| 345 | image: &image, |
| 346 | harness: &harness, |
| 347 | network: &name, |
| 348 | volume: volume.as_deref(), |
| 349 | limits: &limits, |
| 350 | env_names: env.keys().map(String::as_str).collect(), |
| 351 | engine_args: engine.as_ref().map(JobEngine::job_args).unwrap_or_default(), |
| 352 | }; |
| 353 | let mut command = docker(); |
| 354 | command.args(run_args(&plan)); |
| 355 | command.envs(&env); |
| 356 | log(&format!( |
| 357 | "Running {} {} in {image}: its own container and network, capabilities dropped, {} memory, {} CPUs, {} processes{}", |
| 358 | work.kind, |
| 359 | work.name, |
| 360 | limits.memory, |
| 361 | limits.cpus, |
| 362 | limits.pids, |
| 363 | if engine.is_some() { ", Docker of its own when it uses it" } else { "" } |
| 364 | )); |
| 365 | let child = match command.spawn() { |
| 366 | Ok(child) => child, |
| 367 | Err(error) => { |
| 368 | if let Some(engine) = &engine { |
| 369 | engine.remove(); |
| 370 | } |
| 371 | quietly(docker().args(["network", "rm", &name])); |
| 372 | return Err(error).context("could not run docker: is Docker installed and running?"); |
| 373 | } |
| 374 | }; |
| 375 | Ok(Running { |
| 376 | id: work.id.clone(), |
| 377 | kind: work.kind.clone(), |
| 378 | name: work.name.clone(), |
| 379 | child, |
| 380 | container: Some(name.clone()), |
| 381 | network: Some(name), |
| 382 | volume, |
| 383 | engine, |
| 384 | folder: None, |
| 385 | deadline: deadline(work), |
| 386 | }) |
| 387 | } |
| 388 | |
| 389 | #[cfg(unix)] |
| 390 | fn job_engine(config: &Config, isolation: Isolation, name: &str) -> Option<JobEngine> { |
| 391 | if isolation != Isolation::NestedContainer || !config.job_docker { |
| 392 | return None; |
| 393 | } |
| 394 | let image = config.job_docker_image.as_deref().unwrap_or(super::engine::DEFAULT_JOB_ENGINE_IMAGE); |
| 395 | match JobEngine::prepare(name, &config.runner, image) { |
| 396 | Ok(engine) => Some(engine), |
| 397 | Err(error) => { |
| 398 | log(&format!("This job has no Docker of its own: {error:#}")); |
| 399 | None |
| 400 | } |
| 401 | } |
| 402 | } |
| 403 | |
| 404 | #[cfg(not(unix))] |
| 405 | fn job_engine(_config: &Config, _isolation: Isolation, _name: &str) -> Option<JobEngine> { |
| 406 | None |
| 407 | } |
| 408 | |
| 409 | fn directly(config: &Config, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> { |
| 410 | let me = std::env::current_exe().context("could not find this program to run the job")?; |
| 411 | let mut command = Command::new(me); |
| 412 | let mut folder = None; |
| 413 | if work.kind == "workflow" { |
| 414 | // GitHub's layout, in a folder of its own. |
| 415 | let root = config.work_dir.join(container_name(&work.id)); |
| 416 | let _ = std::fs::remove_dir_all(&root); |
| 417 | std::fs::create_dir_all(&root).with_context(|| format!("could not make {}", root.display()))?; |
| 418 | command.env("G1T_RUNNER_ROOT", &root); |
| 419 | folder = Some(root); |
| 420 | } else if !cfg!(target_os = "linux") || std::fs::create_dir_all("/work").is_err() { |
| 421 | // Agent work expects g1t's sandbox layout (/work), which only a |
| 422 | // Linux machine (or a container) set aside for it has. |
| 423 | bail!("agent work runs in Docker, or directly on Linux where /work can be written; this runner can do neither"); |
| 424 | } |
| 425 | // Its own variables, less anything of the runner's. |
| 426 | for (name, _) in std::env::vars_os() { |
| 427 | if name.to_string_lossy().starts_with("G1T_RUNNER_") && name != "G1T_RUNNER_ROOT" { |
| 428 | command.env_remove(&name); |
| 429 | } |
| 430 | } |
| 431 | command.envs(&env); |
| 432 | // The job's log says it ran unsandboxed (actions/mod.rs). |
| 433 | command.env(JOB_NOTICE, sandbox::HOST_MODE_IN_JOB); |
| 434 | // A group of its own, so stopping it stops what its steps started. |
| 435 | #[cfg(unix)] |
| 436 | std::os::unix::process::CommandExt::process_group(&mut command, 0); |
| 437 | log(&format!("Running {} {} on this machine, unsandboxed (host mode, deprecated)", work.kind, work.name)); |
| 438 | let child = command.spawn().context("could not start the job's process")?; |
| 439 | Ok(Running { |
| 440 | id: work.id.clone(), |
| 441 | kind: work.kind.clone(), |
| 442 | name: work.name.clone(), |
| 443 | child, |
| 444 | container: None, |
| 445 | network: None, |
| 446 | volume: None, |
| 447 | engine: None, |
| 448 | folder, |
| 449 | deadline: deadline(work), |
| 450 | }) |
| 451 | } |
| 452 | |
| 453 | /// A warning the harness puts at the top of the job's log. |
| 454 | pub const JOB_NOTICE: &str = "G1T_JOB_NOTICE"; |
| 455 | |
| 456 | fn deadline(work: &Assignment) -> Instant { |
| 457 | Instant::now() + Duration::from_secs(u64::from(work.timeout_minutes.max(1)) * 60 + 10 * 60) |
| 458 | } |
| 459 | |
| 460 | /// Whether Docker answers. |
| 461 | pub fn docker_ready() -> bool { |
| 462 | docker() |
| 463 | .args(["version", "--format", "{{.Server.Version}}"]) |
| 464 | .stdout(Stdio::null()) |
| 465 | .stderr(Stdio::null()) |
| 466 | .status() |
| 467 | .is_ok_and(|status| status.success()) |
| 468 | } |
| 469 | |
| 470 | /// Removes what jobs of this runner's left behind, if it stopped while one ran. |
| 471 | pub fn sweep(config: &Config) { |
| 472 | let label = format!("label=sh.g1t.runner={}", config.runner); |
| 473 | for (list, remove) in [ |
| 474 | (vec!["ps", "-aq", "--filter", &label], vec!["rm", "-f", "-v"]), |
| 475 | (vec!["network", "ls", "-q", "--filter", &label], vec!["network", "rm"]), |
| 476 | (vec!["volume", "ls", "-q", "--filter", &label], vec!["volume", "rm", "-f"]), |
| 477 | ] { |
| 478 | let Ok(output) = docker().args(&list).output() else { return }; |
| 479 | let ids: Vec<String> = String::from_utf8_lossy(&output.stdout).split_whitespace().map(str::to_owned).collect(); |
| 480 | if !ids.is_empty() { |
| 481 | quietly(docker().args(&remove).args(&ids)); |
| 482 | } |
| 483 | } |
| 484 | } |
| 485 | |
| 486 | #[cfg(test)] |
| 487 | mod tests { |
| 488 | use super::*; |
| 489 | use serde_json::json; |
| 490 | |
| 491 | fn config() -> Config { |
| 492 | Config { |
| 493 | url: "https://g1t.sh".into(), |
| 494 | api: "https://api.g1t.sh".into(), |
| 495 | runner: "rnr_1".into(), |
| 496 | name: "a".into(), |
| 497 | credential: "g1tr_secret".into(), |
| 498 | workspace: "acme".into(), |
| 499 | repo: None, |
| 500 | group: None, |
| 501 | labels: vec![], |
| 502 | ephemeral: false, |
| 503 | work_dir: PathBuf::from("/tmp/w"), |
| 504 | docker: true, |
| 505 | image: None, |
| 506 | agent_image: None, |
| 507 | harness: None, |
| 508 | auto_update: false, |
| 509 | memory: None, |
| 510 | cpus: None, |
| 511 | pids_limit: None, |
| 512 | job_docker: true, |
| 513 | job_docker_image: None, |
| 514 | } |
| 515 | } |
| 516 | |
| 517 | fn assignment(kind: &str) -> Assignment { |
| 518 | serde_json::from_value(json!({ |
| 519 | "kind": kind, "id": "job_1", "name": "build", "repo": "acme/web", "timeout_minutes": 60, |
| 520 | "token": "jobtoken", "env": { "MODE": "checks", "G1T_TOKEN": "g1t_run", "G1T_API": "https://elsewhere" } |
| 521 | })) |
| 522 | .unwrap() |
| 523 | } |
| 524 | |
| 525 | #[test] |
| 526 | fn a_workflow_job_gets_its_own_token_and_never_the_runners() { |
| 527 | let env = harness_env(&config(), &assignment("workflow")).unwrap(); |
| 528 | assert_eq!(env["MODE"], "actions"); |
| 529 | assert_eq!(env["ACTIONS_JOB"], "job_1"); |
| 530 | assert_eq!(env["ACTIONS_TOKEN"], "jobtoken"); |
| 531 | assert_eq!(env["G1T_API"], "https://api.g1t.sh"); |
| 532 | assert!(env.values().all(|value| !value.contains("g1tr_"))); |
| 533 | } |
| 534 | |
| 535 | #[test] |
| 536 | fn agent_work_gets_the_sandboxs_environment_and_this_api() { |
| 537 | let env = harness_env(&config(), &assignment("agent")).unwrap(); |
| 538 | assert_eq!(env["MODE"], "checks"); |
| 539 | assert_eq!(env["G1T_TOKEN"], "g1t_run"); |
| 540 | assert_eq!(env["G1T_API"], "https://api.g1t.sh"); |
| 541 | assert_eq!(env["G1T_ABUSE"], "off"); |
| 542 | } |
| 543 | |
| 544 | #[test] |
| 545 | fn unknown_work_is_refused() { |
| 546 | assert!(harness_env(&config(), &assignment("deploy")).is_err()); |
| 547 | } |
| 548 | |
| 549 | #[test] |
| 550 | fn container_names_are_dockers() { |
| 551 | assert_eq!(container_name("job_01ab"), "g1t-job_01ab"); |
| 552 | assert_eq!(container_name("a/b c"), "g1t-a-b-c"); |
| 553 | } |
| 554 | |
| 555 | fn args_for(kind: &str, engine_args: Vec<String>) -> (Vec<String>, BTreeMap<String, String>) { |
| 556 | let config = config(); |
| 557 | let env = harness_env(&config, &assignment(kind)).unwrap(); |
| 558 | let limits = Limits { memory: "6144m".into(), cpus: "4".into(), pids: 4096 }; |
| 559 | let harness = PathBuf::from("/usr/local/bin/g1t-runner"); |
| 560 | let plan = Plan { |
| 561 | kind, |
| 562 | name: "g1t-job_1", |
| 563 | runner: "rnr_1", |
| 564 | image: DEFAULT_IMAGE, |
| 565 | harness: &harness, |
| 566 | network: "g1t-job_1", |
| 567 | volume: (kind == "workflow").then_some("g1t-job_1-work"), |
| 568 | limits: &limits, |
| 569 | env_names: env.keys().map(String::as_str).collect(), |
| 570 | engine_args, |
| 571 | }; |
| 572 | (run_args(&plan), env.clone()) |
| 573 | } |
| 574 | |
| 575 | fn value_of<'a>(args: &'a [String], flag: &str) -> Vec<&'a str> { |
| 576 | args.windows(2).filter(|w| w[0] == flag).map(|w| w[1].as_str()).collect() |
| 577 | } |
| 578 | |
| 579 | #[test] |
| 580 | fn a_jobs_container_is_walled_in() { |
| 581 | let (args, _) = args_for("workflow", Vec::new()); |
| 582 | assert_eq!(args[0], "run"); |
| 583 | assert!(args.contains(&"--rm".to_owned())); |
| 584 | assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]); |
| 585 | assert_eq!(value_of(&args, "--cap-add"), JOB_CAPS.to_vec()); |
| 586 | assert_eq!(value_of(&args, "--security-opt"), vec!["no-new-privileges"]); |
| 587 | assert_eq!(value_of(&args, "--pids-limit"), vec!["4096"]); |
| 588 | assert_eq!(value_of(&args, "--memory"), vec!["6144m"]); |
| 589 | assert_eq!(value_of(&args, "--memory-swap"), vec!["6144m"]); |
| 590 | assert_eq!(value_of(&args, "--cpus"), vec!["4"]); |
| 591 | assert_eq!(value_of(&args, "--network"), vec!["g1t-job_1"]); |
| 592 | for forbidden in ["--privileged", "--pid", "--userns", "--device", "host"] { |
| 593 | assert!(!args.iter().any(|a| a == forbidden), "{forbidden} in {args:?}"); |
| 594 | } |
| 595 | assert!(!JOB_CAPS.iter().any(|c| c.starts_with("SYS_") || *c == "NET_ADMIN" || *c == "NET_RAW")); |
| 596 | } |
| 597 | |
| 598 | #[test] |
| 599 | fn a_jobs_only_mounts_are_the_harness_and_its_own_volume() { |
| 600 | let (args, _) = args_for("workflow", Vec::new()); |
| 601 | let mounts = value_of(&args, "-v"); |
| 602 | assert_eq!(mounts, vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro", "g1t-job_1-work:/home/runner"]); |
| 603 | assert!(!args.iter().any(|a| a.contains("docker.sock")), "no engine socket: {args:?}"); |
| 604 | assert!(!args.iter().any(|a| a == "--mount")); |
| 605 | } |
| 606 | |
| 607 | #[test] |
| 608 | fn credentials_go_by_name_and_the_runners_never() { |
| 609 | let (args, env) = args_for("workflow", Vec::new()); |
| 610 | let passed = value_of(&args, "-e"); |
| 611 | assert!(passed.contains(&"ACTIONS_TOKEN")); |
| 612 | // Never a value on the command line, and never the runner's credential. |
| 613 | assert!(!args.iter().any(|a| a.contains("jobtoken"))); |
| 614 | assert!(!args.iter().any(|a| a.contains("g1tr_"))); |
| 615 | assert!(env.values().all(|v| !v.contains("g1tr_secret"))); |
| 616 | assert!(!env.contains_key("G1T_RUNNER_TOKEN")); |
| 617 | } |
| 618 | |
| 619 | #[test] |
| 620 | fn agent_work_keeps_sudo_for_its_guardrails_and_every_other_wall() { |
| 621 | let (args, _) = args_for("agent", Vec::new()); |
| 622 | assert!(value_of(&args, "--security-opt").is_empty()); |
| 623 | assert!(value_of(&args, "--cap-add").contains(&"AUDIT_WRITE")); |
| 624 | assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]); |
| 625 | assert_eq!(value_of(&args, "-v"), vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro"]); |
| 626 | } |
| 627 | |
| 628 | #[test] |
| 629 | fn a_job_with_docker_gets_a_socket_of_its_own_not_the_runners() { |
| 630 | 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()]; |
| 631 | let (args, _) = args_for("workflow", engine_args); |
| 632 | let mounts = value_of(&args, "-v"); |
| 633 | assert!(mounts.contains(&"/run/g1t-runner/jobs/g1t-job_1/proxy:/run/g1t-docker:ro")); |
| 634 | assert!(!mounts.iter().any(|m| m.starts_with("/var/run/docker.sock") || m.starts_with("/run/g1t-runner/docker.sock"))); |
| 635 | // The entrypoint and image stay last. |
| 636 | assert_eq!(&args[args.len() - 3..], ["--entrypoint", "/opt/g1t/g1t-runner", DEFAULT_IMAGE]); |
| 637 | } |
| 638 | |
| 639 | #[test] |
| 640 | fn limits_default_sensibly() { |
| 641 | assert_eq!(default_memory("MemTotal: 16384000 kB\nMemFree: 1 kB\n"), Some("12000m".into())); |
| 642 | assert_eq!(default_memory(""), None); |
| 643 | let mut config = config(); |
| 644 | config.memory = Some("2g".into()); |
| 645 | config.pids_limit = Some(100); |
| 646 | let limits = limits(&config); |
| 647 | assert_eq!(limits.memory, "2g"); |
| 648 | assert_eq!(limits.pids, 100); |
| 649 | assert!(limits.cpus.parse::<f64>().unwrap() >= 1.0); |
| 650 | } |
| 651 | |
| 652 | #[test] |
| 653 | fn host_mode_refuses_a_public_repositorys_work() { |
| 654 | let mut work = assignment("workflow"); |
| 655 | work.visibility = Some("public".into()); |
| 656 | let refused = refuse_public_on_host(&work).unwrap_err().to_string(); |
| 657 | assert!(refused.contains("acme/web is public"), "{refused}"); |
| 658 | work.visibility = Some("private".into()); |
| 659 | assert!(refuse_public_on_host(&work).is_ok()); |
| 660 | work.visibility = None; |
| 661 | assert!(refuse_public_on_host(&work).is_ok()); |
| 662 | // And refuses it before anything runs. |
| 663 | work.visibility = Some("public".into()); |
| 664 | assert!(start(&config(), Isolation::Host, Path::new("/tmp"), &work).is_err()); |
| 665 | } |
| 666 | } |