g1t/crates/runner/src/selfhosted/exec.rs
| 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 | } |