flagon-io/g1t

public

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/crates/runner/src/checks.rs

246 lines8,082 bytesCodeBlame
1//! Runs commands against one commit and reports how each went: the merge
2//! queue's runner of commands, and `MODE=checks`, which ran commands written
3//! on issues before a pull request's checks were its workflows. g1t no
4//! longer starts that mode; it stays for a sandbox already under way.
5//!
6//! The sandbox holds nothing but that commit: no agent has run here, so a
7//! passing result says something about the code and not about what an
8//! agent left lying around.
9//!
10//! Configuration comes from the environment:
11//!
12//! - `G1T_API`, `CHECK_RUN`, `CHECK_TOKEN`: where and how to report.
13//! - `GIT_REMOTE`, `GIT_COMMIT`: what to check out.
14//! - `G1T_USER`, `G1T_TOKEN`: to read the repository, if it is private.
15//! - `CHECKS`: the commands, as a JSON array.
16
17use std::path::Path;
18use std::process::{Command, Stdio};
19use std::time::Instant;
20
21use anyhow::{Context, Result, bail};
22use serde::Serialize;
23
24use crate::{WORKDIR, auth_option, env, git};
25
26/// The longest one command may run.
27const COMMAND_TIMEOUT_SECONDS: u32 = 10 * 60;
28/// How much of a command's output is kept: the end, where failures are.
29const MAX_OUTPUT_CHARS: usize = 12_000;
30/// What `timeout` exits with when it had to stop the command.
31const TIMED_OUT: i32 = 124;
32const KILLED: i32 = 137;
33
34#[derive(Debug, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub(crate) struct CheckResult {
37 pub(crate) command: String,
38 pub(crate) passed: bool,
39 exit_code: Option<i32>,
40 output: String,
41 duration_ms: u64,
42}
43
44impl CheckResult {
45 pub(crate) fn output_text(&self) -> &str {
46 &self.output
47 }
48}
49
50/// The last `limit` characters of `text`, saying so if any were dropped.
51fn tail(text: &str, limit: usize) -> String {
52 let length = text.chars().count();
53 if length <= limit {
54 return text.to_owned();
55 }
56 let kept: String = text.chars().skip(length - limit).collect();
57 format!("… (earlier output not shown)\n{kept}")
58}
59
60pub(crate) fn redact(text: &str, secrets: &[String]) -> String {
61 secrets.iter().fold(text.to_owned(), |text, secret| {
62 text.replace(secret, "[redacted]")
63 })
64}
65
66/// Runs one command in the checkout, without this process's credentials.
67pub(crate) fn run_command(command: &str, workdir: &Path, secrets: &[String]) -> CheckResult {
68 crate::abuse::touch();
69 // Mining is never a check's or a build's job (abuse.rs).
70 if let Some(miner) = crate::abuse::miner_in(command) {
71 return CheckResult {
72 command: command.to_owned(),
73 passed: false,
74 exit_code: None,
75 output: format!("g1t does not run cryptocurrency miners ({miner}). This command was not run."),
76 duration_ms: 0,
77 };
78 }
79 let started = Instant::now();
80 let output = Command::new("timeout")
81 .args([
82 "--signal=KILL",
83 &COMMAND_TIMEOUT_SECONDS.to_string(),
84 "sh",
85 "-c",
86 // One stream, in the order it was written.
87 &format!("( {command}\n) 2>&1"),
88 ])
89 .current_dir(workdir)
90 .env_remove("G1T_TOKEN")
91 .env_remove("CHECK_TOKEN")
92 .env_remove("DEPLOY_TOKEN")
93 .stdin(Stdio::null())
94 .output();
95 let duration_ms = started.elapsed().as_millis() as u64;
96 match output {
97 Ok(output) => {
98 let code = output.status.code();
99 let timed_out = matches!(code, Some(TIMED_OUT | KILLED) | None);
100 let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
101 if timed_out {
102 text.push_str(&format!(
103 "\nStopped after {} minutes.",
104 COMMAND_TIMEOUT_SECONDS / 60
105 ));
106 }
107 CheckResult {
108 command: command.to_owned(),
109 passed: output.status.success(),
110 exit_code: code.filter(|_| !timed_out),
111 output: redact(&tail(text.trim_end(), MAX_OUTPUT_CHARS), secrets),
112 duration_ms,
113 }
114 }
115 Err(error) => CheckResult {
116 command: command.to_owned(),
117 passed: false,
118 exit_code: None,
119 output: format!("Could not start the command: {error}"),
120 duration_ms,
121 },
122 }
123}
124
125struct Reporter {
126 url: String,
127 token: String,
128}
129
130impl Reporter {
131 fn send(&self, mut body: serde_json::Value) -> Result<()> {
132 body["token"] = self.token.clone().into();
133 ureq::post(&self.url)
134 .send_json(body)
135 .context("could not report the check run")?;
136 Ok(())
137 }
138}
139
140fn check_out(secrets: &[String]) -> Result<()> {
141 let remote = env("GIT_REMOTE")?;
142 let commit = env("GIT_COMMIT")?;
143 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
144 std::fs::create_dir_all("/work")?;
145 let workdir = Path::new(WORKDIR);
146 let cloned = crate::clone::clone(Path::new("/work"), &auth, &[], &remote, WORKDIR)
147 .and_then(|_| git(workdir, &["rev-parse", "--abbrev-ref", "HEAD"]))
148 .and_then(|branch| crate::clone::ensure(workdir, &auth, "origin", &branch, &commit))
149 .and_then(|_| {
150 git(
151 Path::new(WORKDIR),
152 &[
153 "-c",
154 "advice.detachedHead=false",
155 "checkout",
156 "--quiet",
157 &commit,
158 ],
159 )
160 });
161 if let Err(error) = cloned {
162 bail!("{}", redact(&format!("{error:#}"), secrets));
163 }
164 Ok(())
165}
166
167pub fn main() -> i32 {
168 let reporter = match (env("G1T_API"), env("CHECK_RUN"), env("CHECK_TOKEN")) {
169 (Ok(api), Ok(run), Ok(token)) => Reporter {
170 url: format!("{api}/checks/{run}"),
171 token,
172 },
173 _ => {
174 eprintln!("g1t-runner: G1T_API, CHECK_RUN and CHECK_TOKEN must be set");
175 return 2;
176 }
177 };
178 let secrets: Vec<String> = ["G1T_TOKEN", "CHECK_TOKEN"]
179 .iter()
180 .filter_map(|name| std::env::var(name).ok())
181 .filter(|secret| !secret.is_empty())
182 .collect();
183 let commands: Vec<String> = std::env::var("CHECKS")
184 .ok()
185 .and_then(|json| serde_json::from_str(&json).ok())
186 .unwrap_or_default();
187
188 // Says the run has started.
189 if let Err(error) = reporter.send(serde_json::json!({})) {
190 eprintln!("g1t-runner: {error:#}");
191 return 1;
192 }
193 let report = match check_out(&secrets) {
194 Err(error) => serde_json::json!({
195 "error": format!("The commit could not be checked out: {error:#}"),
196 }),
197 Ok(()) => {
198 let results: Vec<CheckResult> = commands
199 .iter()
200 .map(|command| run_command(command, Path::new(WORKDIR), &secrets))
201 .collect();
202 serde_json::json!({ "results": results })
203 }
204 };
205 match reporter.send(report) {
206 Ok(()) => 0,
207 Err(error) => {
208 eprintln!("g1t-runner: {error:#}");
209 1
210 }
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn long_output_keeps_its_end() {
220 let text = format!("{}END", "x".repeat(50));
221 let kept = tail(&text, 10);
222 assert!(kept.ends_with("xxxxxxxEND"));
223 assert!(kept.starts_with("… (earlier output not shown)"));
224 assert_eq!(tail("short", 10), "short");
225 }
226
227 #[test]
228 fn secrets_do_not_reach_a_report() {
229 let secrets = vec!["g1t_secret".to_owned()];
230 assert_eq!(redact("token=g1t_secret", &secrets), "token=[redacted]");
231 }
232
233 #[cfg(unix)]
234 #[test]
235 fn a_command_passes_or_fails_by_its_exit_code() {
236 let here = std::env::temp_dir();
237 let passed = run_command("echo out; echo err >&2", &here, &[]);
238 assert!(passed.passed);
239 assert_eq!(passed.exit_code, Some(0));
240 assert_eq!(passed.output, "out\nerr");
241 let failed = run_command("echo nope; exit 3", &here, &[]);
242 assert!(!failed.passed);
243 assert_eq!(failed.exit_code, Some(3));
244 assert_eq!(failed.output, "nope");
245 }
246}