Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
766 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 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
88use super::native::{self, WsbJob};
89
90#[cfg(unix)]
91use super::engine::JobEngine;
92
93/// No job has an engine of its own off Unix.
94#[cfg(not(unix))]
95pub struct JobEngine;
96
97#[cfg(not(unix))]
98impl 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.
106fn docker() -> Command {
107 #[cfg(unix)]
108 return super::engine::docker();
109 #[cfg(not(unix))]
110 Command::new("docker")
111}
112
113fn quietly(command: &mut Command) {
114 let _ = command.stdout(Stdio::null()).stderr(Stdio::null()).status();
115}
116
117impl 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.
191fn 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.
197fn 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 // That token is where this computer can see it (a Windows job's
207 // script in the job's folder, a container's environment): the
208 // harness spends it on fetching the job and works on with a new
209 // one only it has.
210 env.insert(crate::actions::ROTATE_TOKEN.into(), "1".into());
211 }
212 "agent" => {
213 for (name, value) in work.env.clone().unwrap_or_default() {
214 let value = match value {
215 serde_json::Value::String(text) => text,
216 other => other.to_string(),
217 };
218 env.insert(name, value);
219 }
220 if !env.contains_key("MODE") && !env.contains_key("PROMPT") {
221 bail!("the agent work came without its environment");
222 }
223 // Where its API calls go: this installation's.
224 env.insert("G1T_API".into(), config.api.clone());
225 }
226 other => bail!("this runner does not know work of kind {other}; update it"),
227 }
228 // The machine is the workspace's own: no mining watch.
229 env.insert("G1T_ABUSE".into(), "off".into());
230 Ok(env)
231}
232
233/// Starts work. The harness reports a workflow job's steps and log itself;
234/// the runner tells g1t how the process ended.
235pub fn start(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment) -> Result<Running> {
236 let env = harness_env(config, work)?;
237 match isolation {
238 Isolation::Host => {
239 refuse_public_on_host(work)?;
240 directly(config, work, env)
241 }
242 Isolation::Vm => in_native_vm(config, folder, work, env),
243 _ => in_docker(config, isolation, folder, work, env),
244 }
245}
246
247/// A memory size as `--memory` takes it (`8g`, `4096m`), in MiB.
248pub fn mib(text: &str) -> Option<u64> {
249 let text = text.trim().to_ascii_lowercase();
250 let (number, unit) = text.split_at(text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len()));
251 let number: u64 = number.parse().ok()?;
252 match unit.trim_end_matches('b').trim_end_matches('i') {
253 "" | "m" => Some(number),
254 "g" => Some(number * 1024),
255 "k" => Some(number / 1024),
256 _ => None,
257 }
258}
259
260/// A job native to this OS, in a throwaway VM of its own (native.rs):
261/// Windows Sandbox on Windows. Agent work needs g1t's Linux sandbox
262/// layout and runs on a Linux runner.
263fn in_native_vm(config: &Config, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> {
264 if work.kind != "workflow" {
265 bail!("agent work runs on Linux runners: this one runs jobs native to {}", std::env::consts::OS);
266 }
267 if !cfg!(windows) {
268 bail!("this runner has no sandbox for jobs native to {}", std::env::consts::OS);
269 }
270 let memory = config.memory.as_deref().and_then(mib).unwrap_or(4096);
271 let drop = folder.join("native").join(container_name(&work.id));
272 let job = WsbJob::start(drop, &env, memory)?;
273 let child = match job.exec().spawn() {
274 Ok(child) => child,
275 Err(error) => {
276 job.clean();
277 return Err(error).context("could not run wsb exec");
278 }
279 };
280 log(&format!(
281 "Running {} {} in a Windows Sandbox of its own ({memory} MiB): no folder of this machine's but its read-only harness, discarded after",
282 work.kind, work.name
283 ));
284 Ok(Running {
285 id: work.id.clone(),
286 repo: work.repo.clone(),
287 kind: work.kind.clone(),
288 name: work.name.clone(),
289 child,
290 container: None,
291 network: None,
292 volume: None,
293 engine: None,
294 folder: None,
295 native: Some(job),
296 exit: None,
297 deadline: deadline(work),
298 })
299}
300
301/// Host mode does not take a public repository's work: anyone can propose
302/// code to one, and here it would run on the machine itself.
303fn refuse_public_on_host(work: &Assignment) -> Result<()> {
304 match work.visibility.as_deref() {
305 Some("public") => bail!("{}", sandbox::host_mode_refuses(&work.repo)),
306 Some(_) => Ok(()),
307 None => {
308 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));
309 Ok(())
310 }
311 }
312}
313
314/// Each job container's limits.
315#[derive(Debug, Clone, PartialEq)]
316pub struct Limits {
317 pub memory: String,
318 pub cpus: String,
319 pub pids: u32,
320}
321
322/// Three quarters of `MemTotal` in `/proc/meminfo`, in MiB, which under
323/// Sysbox is the runner container's own memory.
324fn default_memory(meminfo: &str) -> Option<String> {
325 let kb: u64 = meminfo.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
326 let mib = kb / 1024 * 3 / 4;
327 (mib >= 256).then(|| format!("{mib}m"))
328}
329
330pub fn limits(config: &Config) -> Limits {
331 let memory = config
332 .memory
333 .clone()
334 .or_else(|| default_memory(&std::fs::read_to_string("/proc/meminfo").unwrap_or_default()))
335 .unwrap_or_else(|| "4g".into());
336 let cpus = config.cpus.clone().unwrap_or_else(|| std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2).to_string());
337 Limits { memory, cpus, pids: config.pids_limit.unwrap_or(DEFAULT_PIDS_LIMIT) }
338}
339
340/// Everything a job's `docker run` is made from.
341pub struct Plan<'a> {
342 pub kind: &'a str,
343 pub name: &'a str,
344 pub runner: &'a str,
345 pub image: &'a str,
346 pub harness: &'a Path,
347 pub network: &'a str,
348 /// The job's work volume, for a workflow job's `/home/runner`.
349 pub volume: Option<&'a str>,
350 pub limits: &'a Limits,
351 /// The names of the harness's variables, whose values come from the
352 /// runner's process, never its command line.
353 pub env_names: Vec<&'a str>,
354 /// What the job's own engine adds (`JobEngine::job_args`).
355 pub engine_args: Vec<String>,
356}
357
358/// The arguments of `docker` that start a job's container.
359pub fn run_args(plan: &Plan) -> Vec<String> {
360 let mut args: Vec<String> = Vec::new();
361 fn add(args: &mut Vec<String>, items: &[&str]) {
362 args.extend(items.iter().map(|s| s.to_string()));
363 }
364 add(&mut args, &["run", "--rm", "--name", plan.name, "--label", &format!("sh.g1t.runner={}", plan.runner)]);
365 add(&mut args, &["--pull", "missing", "--init"]);
366 // The walls.
367 add(&mut args, &["--cap-drop", "ALL"]);
368 let mut caps: Vec<&str> = JOB_CAPS.to_vec();
369 if plan.kind == "agent" {
370 caps.extend(AGENT_EXTRA_CAPS);
371 } else {
372 add(&mut args, &["--security-opt", "no-new-privileges"]);
373 }
374 for cap in caps {
375 add(&mut args, &["--cap-add", cap]);
376 }
377 let pids = plan.limits.pids.to_string();
378 add(&mut args, &["--pids-limit", &pids, "--memory", &plan.limits.memory, "--memory-swap", &plan.limits.memory, "--cpus", &plan.limits.cpus]);
379 add(&mut args, &["--network", plan.network, "--ipc", "private"]);
380 // The job is in its `container:` image already: the harness inside
381 // does not start it again (actions/containers.rs).
382 add(&mut args, &["-e", "G1T_JOB_CONTAINER=1"]);
383 for name in &plan.env_names {
384 add(&mut args, &["-e", name]);
385 }
386 // Its only mounts: the harness, read-only, and its own volume.
387 add(&mut args, &["-v", &format!("{}:/opt/g1t/g1t-runner:ro", plan.harness.display())]);
388 if let Some(volume) = plan.volume {
389 add(&mut args, &["-v", &format!("{volume}:/home/runner")]);
390 }
391 args.extend(plan.engine_args.iter().cloned());
392 add(&mut args, &["--entrypoint", "/opt/g1t/g1t-runner", plan.image]);
393 args
394}
395
396fn in_docker(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> {
397 let image = match work.kind.as_str() {
398 "agent" => match config.agent_image.clone().or_else(|| update::agent_image(config)) {
399 Some(image) => image,
400 None => bail!(
401 "agent work needs an image with git, Node and the agent's CLI: register this runner with --agent-image IMAGE"
402 ),
403 },
404 _ => work.image.clone().or_else(|| config.image.clone()).unwrap_or_else(|| DEFAULT_IMAGE.to_owned()),
405 };
406 #[cfg(unix)]
407 if super::engine::in_use() {
408 super::engine::ensure_running()?;
409 }
410 let harness = update::linux_harness(config, folder)?;
411 let name = container_name(&work.id);
412 // What an earlier run of this job may have left.
413 quietly(docker().args(["rm", "-f", "-v", &name]));
414 quietly(docker().args(["network", "rm", &name]));
415 let output = docker()
416 .args(["network", "create", "--label", &format!("sh.g1t.runner={}", config.runner), &name])
417 .output()
418 .context("could not run docker: is Docker installed and running?")?;
419 if !output.status.success() {
420 bail!("could not make the job's network: {}", String::from_utf8_lossy(&output.stderr).trim());
421 }
422 let volume = (work.kind == "workflow").then(|| format!("{name}-work"));
423 if let Some(volume) = &volume {
424 quietly(docker().args(["volume", "rm", "-f", volume]));
425 quietly(docker().args(["volume", "create", "--label", &format!("sh.g1t.runner={}", config.runner), volume]));
426 }
427 // A job's own Docker, only inside the runner's own sandbox: on a
428 // shared engine it would be the machine's.
429 let engine: Option<JobEngine> = job_engine(config, isolation, &name);
430 let limits = limits(config);
431 let plan = Plan {
432 kind: &work.kind,
433 name: &name,
434 runner: &config.runner,
435 image: &image,
436 harness: &harness,
437 network: &name,
438 volume: volume.as_deref(),
439 limits: &limits,
440 env_names: env.keys().map(String::as_str).collect(),
441 engine_args: engine.as_ref().map(JobEngine::job_args).unwrap_or_default(),
442 };
443 let mut command = docker();
444 command.args(run_args(&plan));
445 command.envs(&env);
446 log(&format!(
447 "Running {} {} in {image}: its own container and network, capabilities dropped, {} memory, {} CPUs, {} processes{}",
448 work.kind,
449 work.name,
450 limits.memory,
451 limits.cpus,
452 limits.pids,
453 if engine.is_some() { ", Docker of its own when it uses it" } else { "" }
454 ));
455 let child = match command.spawn() {
456 Ok(child) => child,
457 Err(error) => {
458 if let Some(engine) = &engine {
459 engine.remove();
460 }
461 quietly(docker().args(["network", "rm", &name]));
462 return Err(error).context("could not run docker: is Docker installed and running?");
463 }
464 };
465 Ok(Running {
466 id: work.id.clone(),
467 repo: work.repo.clone(),
468 kind: work.kind.clone(),
469 name: work.name.clone(),
470 child,
471 container: Some(name.clone()),
472 network: Some(name),
473 volume,
474 engine,
475 folder: None,
476 native: None,
477 exit: None,
478 deadline: deadline(work),
479 })
480}
481
482#[cfg(unix)]
483fn job_engine(config: &Config, isolation: Isolation, name: &str) -> Option<JobEngine> {
484 if isolation != Isolation::NestedContainer || !config.job_docker {
485 return None;
486 }
487 let image = config.job_docker_image.as_deref().unwrap_or(super::engine::DEFAULT_JOB_ENGINE_IMAGE);
488 match JobEngine::prepare(name, &config.runner, image) {
489 Ok(engine) => Some(engine),
490 Err(error) => {
491 log(&format!("This job has no Docker of its own: {error:#}"));
492 None
493 }
494 }
495}
496
497#[cfg(not(unix))]
498fn job_engine(_config: &Config, _isolation: Isolation, _name: &str) -> Option<JobEngine> {
499 None
500}
501
502fn directly(config: &Config, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> {
503 let me = std::env::current_exe().context("could not find this program to run the job")?;
504 let mut command = Command::new(me);
505 let mut folder = None;
506 if work.kind == "workflow" {
507 // GitHub's layout, in a folder of its own.
508 let root = config.work_dir.join(container_name(&work.id));
509 let _ = std::fs::remove_dir_all(&root);
510 std::fs::create_dir_all(&root).with_context(|| format!("could not make {}", root.display()))?;
511 command.env("G1T_RUNNER_ROOT", &root);
512 folder = Some(root);
513 } else if !cfg!(target_os = "linux") || std::fs::create_dir_all("/work").is_err() {
514 // Agent work expects g1t's sandbox layout (/work), which only a
515 // Linux machine (or a container) set aside for it has.
516 bail!("agent work runs in Docker, or directly on Linux where /work can be written; this runner can do neither");
517 }
518 // Its own variables, less anything of the runner's.
519 for (name, _) in std::env::vars_os() {
520 if name.to_string_lossy().starts_with("G1T_RUNNER_") && name != "G1T_RUNNER_ROOT" {
521 command.env_remove(&name);
522 }
523 }
524 command.envs(&env);
525 // The job's log says it ran unsandboxed (actions/mod.rs).
526 command.env(JOB_NOTICE, sandbox::HOST_MODE_IN_JOB);
527 // A group of its own, so stopping it stops what its steps started.
528 #[cfg(unix)]
529 std::os::unix::process::CommandExt::process_group(&mut command, 0);
530 log(&format!("Running {} {} on this machine, unsandboxed (host mode, deprecated)", work.kind, work.name));
531 let child = command.spawn().context("could not start the job's process")?;
532 Ok(Running {
533 id: work.id.clone(),
534 repo: work.repo.clone(),
535 kind: work.kind.clone(),
536 name: work.name.clone(),
537 child,
538 container: None,
539 network: None,
540 volume: None,
541 engine: None,
542 folder,
543 native: None,
544 exit: None,
545 deadline: deadline(work),
546 })
547}
548
549/// A warning the harness puts at the top of the job's log.
550pub const JOB_NOTICE: &str = "G1T_JOB_NOTICE";
551
552fn deadline(work: &Assignment) -> Instant {
553 Instant::now() + Duration::from_secs(u64::from(work.timeout_minutes.max(1)) * 60 + 10 * 60)
554}
555
556/// Whether Docker answers.
557pub fn docker_ready() -> bool {
558 docker()
559 .args(["version", "--format", "{{.Server.Version}}"])
560 .stdout(Stdio::null())
561 .stderr(Stdio::null())
562 .status()
563 .is_ok_and(|status| status.success())
564}
565
566/// Removes what jobs of this runner's left behind, if it stopped while one ran.
567pub fn sweep(config: &Config) {
568 let label = format!("label=sh.g1t.runner={}", config.runner);
569 for (list, remove) in [
570 (vec!["ps", "-aq", "--filter", &label], vec!["rm", "-f", "-v"]),
571 (vec!["network", "ls", "-q", "--filter", &label], vec!["network", "rm"]),
572 (vec!["volume", "ls", "-q", "--filter", &label], vec!["volume", "rm", "-f"]),
573 ] {
574 let Ok(output) = docker().args(&list).output() else { return };
575 let ids: Vec<String> = String::from_utf8_lossy(&output.stdout).split_whitespace().map(str::to_owned).collect();
576 if !ids.is_empty() {
577 quietly(docker().args(&remove).args(&ids));
578 }
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use serde_json::json;
586
587 fn config() -> Config {
588 Config {
589 url: "https://g1t.sh".into(),
590 api: "https://api.g1t.sh".into(),
591 runner: "rnr_1".into(),
592 name: "a".into(),
593 credential: "g1tr_secret".into(),
594 workspace: "acme".into(),
595 repo: None,
596 group: None,
597 labels: vec![],
598 ephemeral: false,
599 work_dir: PathBuf::from("/tmp/w"),
600 docker: true,
601 image: None,
602 agent_image: None,
603 harness: None,
604 auto_update: false,
605 memory: None,
606 cpus: None,
607 pids_limit: None,
608 job_docker: true,
609 job_docker_image: None,
610 native: false,
611 }
612 }
613
614 fn assignment(kind: &str) -> Assignment {
615 serde_json::from_value(json!({
616 "kind": kind, "id": "job_1", "name": "build", "repo": "acme/web", "timeout_minutes": 60,
617 "token": "jobtoken", "env": { "MODE": "checks", "G1T_TOKEN": "g1t_run", "G1T_API": "https://elsewhere" }
618 }))
619 .unwrap()
620 }
621
622 #[test]
623 fn a_workflow_job_gets_its_own_token_and_never_the_runners() {
624 let env = harness_env(&config(), &assignment("workflow")).unwrap();
625 assert_eq!(env["MODE"], "actions");
626 assert_eq!(env["ACTIONS_JOB"], "job_1");
627 assert_eq!(env["ACTIONS_TOKEN"], "jobtoken");
628 assert_eq!(env["G1T_API"], "https://api.g1t.sh");
629 assert!(env.values().all(|value| !value.contains("g1tr_")));
630 // That token is spent on fetching the job.
631 assert_eq!(env[crate::actions::ROTATE_TOKEN], "1");
632 assert!(!harness_env(&config(), &assignment("agent")).unwrap().contains_key(crate::actions::ROTATE_TOKEN));
633 }
634
635 #[test]
636 fn agent_work_gets_the_sandboxs_environment_and_this_api() {
637 let env = harness_env(&config(), &assignment("agent")).unwrap();
638 assert_eq!(env["MODE"], "checks");
639 assert_eq!(env["G1T_TOKEN"], "g1t_run");
640 assert_eq!(env["G1T_API"], "https://api.g1t.sh");
641 assert_eq!(env["G1T_ABUSE"], "off");
642 }
643
644 #[test]
645 fn unknown_work_is_refused() {
646 assert!(harness_env(&config(), &assignment("deploy")).is_err());
647 }
648
649 #[test]
650 fn container_names_are_dockers() {
651 assert_eq!(container_name("job_01ab"), "g1t-job_01ab");
652 assert_eq!(container_name("a/b c"), "g1t-a-b-c");
653 }
654
655 fn args_for(kind: &str, engine_args: Vec<String>) -> (Vec<String>, BTreeMap<String, String>) {
656 let config = config();
657 let env = harness_env(&config, &assignment(kind)).unwrap();
658 let limits = Limits { memory: "6144m".into(), cpus: "4".into(), pids: 4096 };
659 let harness = PathBuf::from("/usr/local/bin/g1t-runner");
660 let plan = Plan {
661 kind,
662 name: "g1t-job_1",
663 runner: "rnr_1",
664 image: DEFAULT_IMAGE,
665 harness: &harness,
666 network: "g1t-job_1",
667 volume: (kind == "workflow").then_some("g1t-job_1-work"),
668 limits: &limits,
669 env_names: env.keys().map(String::as_str).collect(),
670 engine_args,
671 };
672 (run_args(&plan), env.clone())
673 }
674
675 fn value_of<'a>(args: &'a [String], flag: &str) -> Vec<&'a str> {
676 args.windows(2).filter(|w| w[0] == flag).map(|w| w[1].as_str()).collect()
677 }
678
679 #[test]
680 fn a_jobs_container_is_walled_in() {
681 let (args, _) = args_for("workflow", Vec::new());
682 assert_eq!(args[0], "run");
683 assert!(args.contains(&"--rm".to_owned()));
684 assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]);
685 assert_eq!(value_of(&args, "--cap-add"), JOB_CAPS.to_vec());
686 assert_eq!(value_of(&args, "--security-opt"), vec!["no-new-privileges"]);
687 assert_eq!(value_of(&args, "--pids-limit"), vec!["4096"]);
688 assert_eq!(value_of(&args, "--memory"), vec!["6144m"]);
689 assert_eq!(value_of(&args, "--memory-swap"), vec!["6144m"]);
690 assert_eq!(value_of(&args, "--cpus"), vec!["4"]);
691 assert_eq!(value_of(&args, "--network"), vec!["g1t-job_1"]);
692 for forbidden in ["--privileged", "--pid", "--userns", "--device", "host"] {
693 assert!(!args.iter().any(|a| a == forbidden), "{forbidden} in {args:?}");
694 }
695 assert!(!JOB_CAPS.iter().any(|c| c.starts_with("SYS_") || *c == "NET_ADMIN" || *c == "NET_RAW"));
696 }
697
698 #[test]
699 fn a_jobs_only_mounts_are_the_harness_and_its_own_volume() {
700 let (args, _) = args_for("workflow", Vec::new());
701 let mounts = value_of(&args, "-v");
702 assert_eq!(mounts, vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro", "g1t-job_1-work:/home/runner"]);
703 assert!(!args.iter().any(|a| a.contains("docker.sock")), "no engine socket: {args:?}");
704 assert!(!args.iter().any(|a| a == "--mount"));
705 }
706
707 #[test]
708 fn credentials_go_by_name_and_the_runners_never() {
709 let (args, env) = args_for("workflow", Vec::new());
710 let passed = value_of(&args, "-e");
711 assert!(passed.contains(&"ACTIONS_TOKEN"));
712 // Never a value on the command line, and never the runner's credential.
713 assert!(!args.iter().any(|a| a.contains("jobtoken")));
714 assert!(!args.iter().any(|a| a.contains("g1tr_")));
715 assert!(env.values().all(|v| !v.contains("g1tr_secret")));
716 assert!(!env.contains_key("G1T_RUNNER_TOKEN"));
717 }
718
719 #[test]
720 fn agent_work_keeps_sudo_for_its_guardrails_and_every_other_wall() {
721 let (args, _) = args_for("agent", Vec::new());
722 assert!(value_of(&args, "--security-opt").is_empty());
723 assert!(value_of(&args, "--cap-add").contains(&"AUDIT_WRITE"));
724 assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]);
725 assert_eq!(value_of(&args, "-v"), vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro"]);
726 }
727
728 #[test]
729 fn a_job_with_docker_gets_a_socket_of_its_own_not_the_runners() {
730 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()];
731 let (args, _) = args_for("workflow", engine_args);
732 let mounts = value_of(&args, "-v");
733 assert!(mounts.contains(&"/run/g1t-runner/jobs/g1t-job_1/proxy:/run/g1t-docker:ro"));
734 assert!(!mounts.iter().any(|m| m.starts_with("/var/run/docker.sock") || m.starts_with("/run/g1t-runner/docker.sock")));
735 // The entrypoint and image stay last.
736 assert_eq!(&args[args.len() - 3..], ["--entrypoint", "/opt/g1t/g1t-runner", DEFAULT_IMAGE]);
737 }
738
739 #[test]
740 fn limits_default_sensibly() {
741 assert_eq!(default_memory("MemTotal: 16384000 kB\nMemFree: 1 kB\n"), Some("12000m".into()));
742 assert_eq!(default_memory(""), None);
743 let mut config = config();
744 config.memory = Some("2g".into());
745 config.pids_limit = Some(100);
746 let limits = limits(&config);
747 assert_eq!(limits.memory, "2g");
748 assert_eq!(limits.pids, 100);
749 assert!(limits.cpus.parse::<f64>().unwrap() >= 1.0);
750 }
751
752 #[test]
753 fn host_mode_refuses_a_public_repositorys_work() {
754 let mut work = assignment("workflow");
755 work.visibility = Some("public".into());
756 let refused = refuse_public_on_host(&work).unwrap_err().to_string();
757 assert!(refused.contains("acme/web is public"), "{refused}");
758 work.visibility = Some("private".into());
759 assert!(refuse_public_on_host(&work).is_ok());
760 work.visibility = None;
761 assert!(refuse_public_on_host(&work).is_ok());
762 // And refuses it before anything runs.
763 work.visibility = Some("public".into());
764 assert!(start(&config(), Isolation::Host, Path::new("/tmp"), &work).is_err());
765 }
766}