g1t/crates/runner/src/selfhosted/exec.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 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 | ||
| 14 | use std::collections::BTreeMap; | |
| 15 | use std::path::{Path, PathBuf}; | |
| 16 | use std::process::{Child, Command, Stdio}; | |
| 17 | use std::time::{Duration, Instant}; | |
| 18 | ||
| 19 | use anyhow::{Context, Result, bail}; | |
| 20 | ||
| 21 | use super::api::Assignment; | |
| 22 | use super::config::Config; | |
| 23 | use 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. | |
| 27 | pub const DEFAULT_IMAGE: &str = "node:24-bookworm"; | |
| 28 | ||
| 29 | /// Work in progress. | |
| 30 | pub 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 | ||
| 41 | impl 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. | |
| 83 | fn 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. | |
| 89 | fn 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. | |
| 122 | pub 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 | ||
| 127 | fn 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 | ||
| 154 | fn 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 | ||
| 185 | fn 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. | |
| 190 | pub 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)] | |
| 200 | mod 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 | } |