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/selfhosted/exec.rs

262 lines10,895 bytesCodeBlame
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. Its
7//! credentials are passed by name (`-e NAME`), never on the command line.
8//! - **Directly** (`--no-docker`): the harness runs as a child process, in a
9//! fresh folder under the runner's work folder that is removed after.
10//!
11//! The runner's own credential never reaches either: a job only ever has
12//! its own short-lived token, as it would in g1t's sandbox.
13
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::process::{Child, Command, Stdio};
17use std::time::{Duration, Instant};
18
19use anyhow::{Context, Result, bail};
20
21use super::api::Assignment;
22use super::config::Config;
23use super::{log, update};
24
25/// The image workflow jobs run in when they name none: Debian with Node,
26/// git and Python, as g1t's sandbox has.
27pub const DEFAULT_IMAGE: &str = "node:24-bookworm";
28
29/// Work in progress.
30pub struct Running {
31 pub id: String,
32 pub kind: String,
33 pub name: String,
34 child: Child,
35 container: Option<String>,
36 folder: Option<PathBuf>,
37 /// Past its time limit and a grace for g1t to say so: stopped here.
38 deadline: Instant,
39}
40
41impl Running {
42 pub fn overdue(&self) -> bool {
43 Instant::now() > self.deadline
44 }
45
46 /// Its exit code once it has ended.
47 pub fn ended(&mut self) -> Option<i32> {
48 match self.child.try_wait() {
49 Ok(Some(status)) => Some(status.code().unwrap_or(1)),
50 Ok(None) => None,
51 Err(_) => Some(1),
52 }
53 }
54
55 /// Stops it: the container, then the process and everything it started.
56 pub fn stop(&mut self) {
57 if let Some(container) = &self.container {
58 let _ = Command::new("docker").args(["kill", container]).stdout(Stdio::null()).stderr(Stdio::null()).status();
59 }
60 let pid = self.child.id().to_string();
61 if cfg!(windows) {
62 let _ = Command::new("taskkill").args(["/T", "/F", "/PID", &pid]).stdout(Stdio::null()).stderr(Stdio::null()).status();
63 } else {
64 // Its own process group (see `directly`): the steps' processes too.
65 let _ = Command::new("kill").args(["-TERM", &format!("-{pid}")]).stdout(Stdio::null()).stderr(Stdio::null()).status();
66 }
67 let _ = self.child.kill();
68 let _ = self.child.wait();
69 }
70
71 /// Removes what it left on the machine.
72 pub fn clean(&mut self) {
73 if let Some(container) = &self.container {
74 let _ = Command::new("docker").args(["rm", "-f", container]).stdout(Stdio::null()).stderr(Stdio::null()).status();
75 }
76 if let Some(folder) = &self.folder {
77 let _ = std::fs::remove_dir_all(folder);
78 }
79 }
80}
81
82/// A name Docker takes, from a job's id.
83fn container_name(id: &str) -> String {
84 let clean: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '-' }).collect();
85 format!("g1t-{clean}")
86}
87
88/// The environment the harness gets for this work.
89fn harness_env(config: &Config, work: &Assignment) -> Result<BTreeMap<String, String>> {
90 let mut env = BTreeMap::new();
91 match work.kind.as_str() {
92 "workflow" => {
93 let Some(token) = &work.token else { bail!("the job came without its token") };
94 env.insert("MODE".into(), "actions".into());
95 env.insert("G1T_API".into(), config.api.clone());
96 env.insert("ACTIONS_JOB".into(), work.id.clone());
97 env.insert("ACTIONS_TOKEN".into(), token.clone());
98 }
99 "agent" => {
100 for (name, value) in work.env.clone().unwrap_or_default() {
101 let value = match value {
102 serde_json::Value::String(text) => text,
103 other => other.to_string(),
104 };
105 env.insert(name, value);
106 }
107 if !env.contains_key("MODE") && !env.contains_key("PROMPT") {
108 bail!("the agent work came without its environment");
109 }
110 // Where its API calls go: this installation's.
111 env.insert("G1T_API".into(), config.api.clone());
112 }
113 other => bail!("this runner does not know work of kind {other}; update it"),
114 }
115 // The machine is the workspace's own: no mining watch.
116 env.insert("G1T_ABUSE".into(), "off".into());
117 Ok(env)
118}
119
120/// Starts work. The harness reports a workflow job's steps and log itself;
121/// the runner tells g1t how the process ended.
122pub fn start(config: &Config, folder: &Path, work: &Assignment) -> Result<Running> {
123 let env = harness_env(config, work)?;
124 if config.docker { in_docker(config, folder, work, env) } else { directly(config, work, env) }
125}
126
127fn in_docker(config: &Config, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> {
128 let image = match work.kind.as_str() {
129 "agent" => match config.agent_image.clone().or_else(|| update::agent_image(config)) {
130 Some(image) => image,
131 None => bail!(
132 "agent work needs an image with git, Node and the agent's CLI: register this runner with --agent-image, or run it with --no-docker on Linux"
133 ),
134 },
135 _ => work.image.clone().or_else(|| config.image.clone()).unwrap_or_else(|| DEFAULT_IMAGE.to_owned()),
136 };
137 let harness = update::linux_harness(config, folder)?;
138 let name = container_name(&work.id);
139 let _ = Command::new("docker").args(["rm", "-f", &name]).stdout(Stdio::null()).stderr(Stdio::null()).status();
140 let mut command = Command::new("docker");
141 command.args(["run", "--rm", "--name", &name, "--label", &format!("sh.g1t.runner={}", config.runner)]);
142 command.args(["--pull", "missing", "--init"]);
143 for name in env.keys() {
144 command.args(["-e", name]);
145 }
146 command.envs(&env);
147 command.args(["-v", &format!("{}:/opt/g1t/g1t-runner:ro", harness.display())]);
148 command.args(["--entrypoint", "/opt/g1t/g1t-runner", &image]);
149 log(&format!("Running {} {} in {image}", work.kind, work.name));
150 let child = command.spawn().context("could not run docker: is Docker installed and running? (or register with --no-docker)")?;
151 Ok(Running { id: work.id.clone(), kind: work.kind.clone(), name: work.name.clone(), child, container: Some(name), folder: None, deadline: deadline(work) })
152}
153
154fn directly(config: &Config, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> {
155 let me = std::env::current_exe().context("could not find this program to run the job")?;
156 let mut command = Command::new(me);
157 let mut folder = None;
158 if work.kind == "workflow" {
159 // GitHub's layout, in a folder of its own.
160 let root = config.work_dir.join(container_name(&work.id));
161 let _ = std::fs::remove_dir_all(&root);
162 std::fs::create_dir_all(&root).with_context(|| format!("could not make {}", root.display()))?;
163 command.env("G1T_RUNNER_ROOT", &root);
164 folder = Some(root);
165 } else if !cfg!(target_os = "linux") || std::fs::create_dir_all("/work").is_err() {
166 // Agent work expects g1t's sandbox layout (/work), which only a
167 // Linux machine (or a container) set aside for it has.
168 bail!("agent work runs in Docker, or directly on Linux where /work can be written; this runner can do neither");
169 }
170 // Its own variables, less anything of the runner's.
171 for (name, _) in std::env::vars_os() {
172 if name.to_string_lossy().starts_with("G1T_RUNNER_") && name != "G1T_RUNNER_ROOT" {
173 command.env_remove(&name);
174 }
175 }
176 command.envs(&env);
177 // A group of its own, so stopping it stops what its steps started.
178 #[cfg(unix)]
179 std::os::unix::process::CommandExt::process_group(&mut command, 0);
180 log(&format!("Running {} {} on this machine", work.kind, work.name));
181 let child = command.spawn().context("could not start the job's process")?;
182 Ok(Running { id: work.id.clone(), kind: work.kind.clone(), name: work.name.clone(), child, container: None, folder, deadline: deadline(work) })
183}
184
185fn deadline(work: &Assignment) -> Instant {
186 Instant::now() + Duration::from_secs(u64::from(work.timeout_minutes.max(1)) * 60 + 10 * 60)
187}
188
189/// Whether Docker answers.
190pub fn docker_ready() -> bool {
191 Command::new("docker")
192 .args(["version", "--format", "{{.Server.Version}}"])
193 .stdout(Stdio::null())
194 .stderr(Stdio::null())
195 .status()
196 .is_ok_and(|status| status.success())
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use serde_json::json;
203
204 fn config() -> Config {
205 Config {
206 url: "https://g1t.sh".into(),
207 api: "https://api.g1t.sh".into(),
208 runner: "rnr_1".into(),
209 name: "a".into(),
210 credential: "g1tr_secret".into(),
211 workspace: "acme".into(),
212 repo: None,
213 group: None,
214 labels: vec![],
215 ephemeral: false,
216 work_dir: PathBuf::from("/tmp/w"),
217 docker: true,
218 image: None,
219 agent_image: None,
220 harness: None,
221 auto_update: false,
222 }
223 }
224
225 fn assignment(kind: &str) -> Assignment {
226 serde_json::from_value(json!({
227 "kind": kind, "id": "job_1", "name": "build", "repo": "acme/web", "timeout_minutes": 60,
228 "token": "jobtoken", "env": { "MODE": "checks", "G1T_TOKEN": "g1t_run", "G1T_API": "https://elsewhere" }
229 }))
230 .unwrap()
231 }
232
233 #[test]
234 fn a_workflow_job_gets_its_own_token_and_never_the_runners() {
235 let env = harness_env(&config(), &assignment("workflow")).unwrap();
236 assert_eq!(env["MODE"], "actions");
237 assert_eq!(env["ACTIONS_JOB"], "job_1");
238 assert_eq!(env["ACTIONS_TOKEN"], "jobtoken");
239 assert_eq!(env["G1T_API"], "https://api.g1t.sh");
240 assert!(env.values().all(|value| !value.contains("g1tr_")));
241 }
242
243 #[test]
244 fn agent_work_gets_the_sandboxs_environment_and_this_api() {
245 let env = harness_env(&config(), &assignment("agent")).unwrap();
246 assert_eq!(env["MODE"], "checks");
247 assert_eq!(env["G1T_TOKEN"], "g1t_run");
248 assert_eq!(env["G1T_API"], "https://api.g1t.sh");
249 assert_eq!(env["G1T_ABUSE"], "off");
250 }
251
252 #[test]
253 fn unknown_work_is_refused() {
254 assert!(harness_env(&config(), &assignment("deploy")).is_err());
255 }
256
257 #[test]
258 fn container_names_are_dockers() {
259 assert_eq!(container_name("job_01ab"), "g1t-job_01ab");
260 assert_eq!(container_name("a/b c"), "g1t-a-b-c");
261 }
262}