Skip to content
666 linesCodeBlameRaw
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
22use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24use std::process::{Child, Command, Stdio};
25use std::time::{Duration, Instant};
26
27use anyhow::{Context, Result, bail};
28
29use super::api::Assignment;
30use super::config::Config;
31use super::sandbox::{self, Isolation};
32use 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.
36pub const DEFAULT_IMAGE: &str = "node:24-bookworm";
37/// The most processes in a job's container, unless the runner says.
38pub 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`.
55pub 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.
62pub const AGENT_EXTRA_CAPS: &[&str] = &["AUDIT_WRITE"];
63
64/// Work in progress.
65pub 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)]
82use super::engine::JobEngine;
83
84/// No job has an engine of its own off Unix.
85#[cfg(not(unix))]
86pub struct JobEngine;
87
88#[cfg(not(unix))]
89impl 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.
97fn docker() -> Command {
98 #[cfg(unix)]
99 return super::engine::docker();
100 #[cfg(not(unix))]
101 Command::new("docker")
102}
103
104fn quietly(command: &mut Command) {
105 let _ = command.stdout(Stdio::null()).stderr(Stdio::null()).status();
106}
107
108impl 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.
163fn 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.
169fn 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.
202pub 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.
213fn 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)]
226pub 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.
234fn 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
240pub 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.
251pub 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.
269pub 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
306fn 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)]
390fn 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))]
405fn job_engine(_config: &Config, _isolation: Isolation, _name: &str) -> Option<JobEngine> {
406 None
407}
408
409fn 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.
454pub const JOB_NOTICE: &str = "G1T_JOB_NOTICE";
455
456fn 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.
461pub 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.
471pub 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)]
487mod 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}