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

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