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