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