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