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 | //! `g1t-runner` on your own machine: a self-hosted runner for g1t. |
| 2 | //! | |
| 3 | //! ```text | |
| 4 | //! g1t-runner register --url https://g1t.sh --token g1trt_… [--name build-01] | |
| 5 | //! [--labels gpu,cuda] [--group Default] [--ephemeral] [--work-dir DIR] | |
| 6 | //! [--no-docker] [--image IMAGE] [--agent-image IMAGE] [--replace] | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 7 | //! [--memory 8g] [--cpus 4] [--pids-limit 4096] [--no-job-docker] |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 8 | //! g1t-runner run [--once] |
| 9 | //! g1t-runner service install|start|stop|status|uninstall | |
| 10 | //! g1t-runner remove | |
| 11 | //! g1t-runner update | |
| 12 | //! g1t-runner version | |
| 13 | //! ``` | |
| 14 | //! | |
| 15 | //! It only ever calls out, to `api.g1t.sh` over HTTPS: it polls for work | |
| 16 | //! (a poll waits up to 20 seconds for some, and is the runner's heartbeat), | |
| 17 | //! runs what it is given with the same harness g1t's sandboxes run (this | |
| 18 | //! program in another mode, see `exec`), and says how it ended. Nothing | |
| 19 | //! listens on the machine. Every command takes `--dir` (default | |
| 20 | //! `~/.g1t-runner`, or `G1T_RUNNER_DIR`), where its configuration is kept. | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 21 | //! |
| 22 | //! How it keeps jobs from the machine is `sandbox`: in its image under | |
| 23 | //! Sysbox (`G1T_RUNNER_SANDBOX=nested`) it runs its own Docker Engine | |
| 24 | //! (`engine`) and refuses to start if it can reach the machine's. | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 25 | |
| 26 | mod api; | |
| 27 | mod config; | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 28 | #[cfg(unix)] |
| 29 | mod engine; | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 30 | mod exec; |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 31 | mod sandbox; |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 32 | mod service; |
| 33 | mod update; | |
| 34 | ||
| 35 | use std::path::{Path, PathBuf}; | |
| 36 | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 37 | use std::time::{Duration, Instant}; | |
| 38 | ||
| 39 | use anyhow::{Context, Result, bail}; | |
| 40 | use serde_json::json; | |
| 41 | ||
| 42 | use api::{Api, Failure}; | |
| 43 | use config::Config; | |
| 44 | ||
| 45 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); | |
| 46 | ||
| 47 | /// Set to stop the loop between polls: a Windows service's stop request. | |
| 48 | pub static STOP: AtomicBool = AtomicBool::new(false); | |
| 49 | ||
| 50 | /// How long a busy runner waits between heartbeats. | |
| 51 | const HEARTBEAT: Duration = Duration::from_secs(10); | |
| 52 | /// How long an idle poll waits for work. | |
| 53 | const IDLE_WAIT_MS: u64 = 20_000; | |
| 54 | /// How often an idle runner checks for a new version. | |
| 55 | const UPDATE_EVERY: Duration = Duration::from_secs(6 * 60 * 60); | |
| 56 | ||
| 57 | const COMMANDS: &[&str] = &["register", "register-and-run", "run", "service", "remove", "update", "version", "--version", "help", "--help", "-h"]; | |
| 58 | ||
| 59 | /// Whether `args` (without the program) are a command of this one, rather | |
| 60 | /// than the harness's `MODE`. | |
| 61 | pub fn is_command(args: &[String]) -> bool { | |
| 62 | args.first().is_some_and(|first| COMMANDS.contains(&first.as_str())) | |
| 63 | } | |
| 64 | ||
| 65 | pub fn log(message: &str) { | |
| 66 | let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); | |
| 67 | println!("{} {message}", g1t_now(now)); | |
| 68 | } | |
| 69 | ||
| 70 | /// RFC 3339 for the log, without pulling in a date library. | |
| 71 | fn g1t_now(ms: u64) -> String { | |
| 72 | let seconds = ms / 1000; | |
| 73 | let (days, rest) = (seconds / 86_400, seconds % 86_400); | |
| 74 | let z = days as i64 + 719_468; | |
| 75 | let era = z.div_euclid(146_097); | |
| 76 | let doe = z.rem_euclid(146_097); | |
| 77 | let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; | |
| 78 | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); | |
| 79 | let mp = (5 * doy + 2) / 153; | |
| 80 | let day = doy - (153 * mp + 2) / 5 + 1; | |
| 81 | let month = if mp < 10 { mp + 3 } else { mp - 9 }; | |
| 82 | let year = yoe + era * 400 + i64::from(month <= 2); | |
| 83 | format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", rest / 3600, rest % 3600 / 60, rest % 60) | |
| 84 | } | |
| 85 | ||
| 86 | /// `--name value` and `--flag` options. | |
| 87 | struct Options { | |
| 88 | values: Vec<(String, String)>, | |
| 89 | flags: Vec<String>, | |
| 90 | rest: Vec<String>, | |
| 91 | } | |
| 92 | ||
| 93 | impl Options { | |
| 94 | fn parse(args: &[String], takes_value: &[&str]) -> Result<Options> { | |
| 95 | let mut options = Options { values: Vec::new(), flags: Vec::new(), rest: Vec::new() }; | |
| 96 | let mut i = 0; | |
| 97 | while i < args.len() { | |
| 98 | let arg = &args[i]; | |
| 99 | if let Some(name) = arg.strip_prefix("--") { | |
| 100 | if let Some((name, value)) = name.split_once('=') { | |
| 101 | options.values.push((name.to_owned(), value.to_owned())); | |
| 102 | } else if takes_value.contains(&name) { | |
| 103 | let Some(value) = args.get(i + 1) else { bail!("--{name} needs a value") }; | |
| 104 | options.values.push((name.to_owned(), value.clone())); | |
| 105 | i += 1; | |
| 106 | } else { | |
| 107 | options.flags.push(name.to_owned()); | |
| 108 | } | |
| 109 | } else { | |
| 110 | options.rest.push(arg.clone()); | |
| 111 | } | |
| 112 | i += 1; | |
| 113 | } | |
| 114 | Ok(options) | |
| 115 | } | |
| 116 | ||
| 117 | fn value(&self, name: &str) -> Option<&str> { | |
| 118 | self.values.iter().rev().find(|(n, _)| n == name).map(|(_, v)| v.as_str()) | |
| 119 | } | |
| 120 | ||
| 121 | fn flag(&self, name: &str) -> bool { | |
| 122 | self.flags.iter().any(|f| f == name) | |
| 123 | } | |
| 124 | ||
| 125 | /// For `register-and-run` in a container: each option may come from | |
| 126 | /// `G1T_RUNNER_<NAME>` instead (`G1T_RUNNER_TOKEN`, `G1T_RUNNER_LABELS`, | |
| 127 | /// `G1T_RUNNER_EPHEMERAL=1`), so a Kubernetes secret can hold the token. | |
| 128 | fn with_environment(mut self) -> Options { | |
| 129 | let env_name = |name: &str| format!("G1T_RUNNER_{}", name.to_ascii_uppercase().replace('-', "_")); | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 130 | for name in VALUE_OPTIONS { |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 131 | if self.value(name).is_none() |
| 132 | && let Ok(value) = std::env::var(env_name(name)) | |
| 133 | && !value.is_empty() | |
| 134 | { | |
| 135 | self.values.push((name.to_owned(), value)); | |
| 136 | } | |
| 137 | } | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 138 | for name in ["ephemeral", "no-docker", "replace", "no-auto-update", "no-job-docker"] { |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 139 | if !self.flag(name) && std::env::var(env_name(name)).is_ok_and(|v| matches!(v.as_str(), "1" | "true" | "yes")) { |
| 140 | self.flags.push(name.to_owned()); | |
| 141 | } | |
| 142 | } | |
| 143 | self | |
| 144 | } | |
| 145 | } | |
| 146 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 147 | /// The options that take a value. |
| 148 | const VALUE_OPTIONS: [&str; 14] = | |
| 149 | ["url", "api", "token", "name", "labels", "group", "work-dir", "image", "agent-image", "harness", "memory", "cpus", "pids-limit", "job-docker-image"]; | |
| 150 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 151 | const HELP: &str = "g1t-runner: a self-hosted runner for g1t. |
| 152 | ||
| 153 | g1t-runner register --url https://g1t.sh --token <registration token> | |
| 154 | [--name NAME] [--labels a,b] [--group GROUP] [--ephemeral] | |
| 155 | [--work-dir DIR] [--no-docker] [--image IMAGE] [--agent-image IMAGE] | |
| 156 | [--api URL] [--harness PATH] [--replace] [--no-auto-update] | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 157 | [--memory 8g] [--cpus 4] [--pids-limit 4096] [--no-job-docker] |
| 158 | [--job-docker-image IMAGE] | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 159 | g1t-runner run [--once] poll for work and run it |
| 160 | g1t-runner register-and-run ... both, for containers; options may be | |
| 161 | G1T_RUNNER_URL, _TOKEN, _LABELS, _EPHEMERAL… | |
| 162 | g1t-runner service install|start|stop|status|uninstall | |
| 163 | g1t-runner remove unregister this runner | |
| 164 | g1t-runner update update to the newest release | |
| 165 | g1t-runner version | |
| 166 | ||
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 167 | In a workflow step (the g1t/deploy and g1t/detect actions use these): |
| 168 | g1t-runner deploy build this checkout and put it on g1t.page | |
| 169 | g1t-runner detect [--root-dir DIR] what that build would find | |
| 170 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 171 | Every command takes --dir DIR (default ~/.g1t-runner, or G1T_RUNNER_DIR). |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 172 | Run it as a container under Sysbox so neither it nor its jobs reach the |
| 173 | machine: docker run --runtime=sysbox-runc … g1t.sh/flagon-io/g1t-runner. | |
| 174 | --no-docker (host mode) is deprecated. | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 175 | Make a registration token under Settings, Runners on g1t.sh. |
| 176 | Docs: https://docs.g1t.sh/guides/self-hosted-runners/"; | |
| 177 | ||
| 178 | /// Runs a command. Returns the process's exit code. | |
| 179 | pub fn main(args: Vec<String>) -> i32 { | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 180 | let mut takes = VALUE_OPTIONS.to_vec(); |
| 181 | takes.push("dir"); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 182 | let options = match Options::parse(&args[1..], &takes) { |
| 183 | Ok(options) => options, | |
| 184 | Err(error) => { | |
| 185 | eprintln!("g1t-runner: {error:#}"); | |
| 186 | return 2; | |
| 187 | } | |
| 188 | }; | |
| 189 | let folder = config::folder(options.value("dir")); | |
| 190 | let outcome = match args[0].as_str() { | |
| 191 | "register" => register(&options, &folder), | |
| 192 | // A container's one command: register unless it already is, then run. | |
| 193 | "register-and-run" => { | |
| 194 | let options = options.with_environment(); | |
| 195 | let ready = if config::load(&folder).is_ok() { Ok(()) } else { register(&options, &folder) }; | |
| 196 | ready.and_then(|()| run(&folder, options.flag("once"))) | |
| 197 | } | |
| 198 | "run" => run(&folder, options.flag("once")), | |
| 199 | "service" => match options.rest.first().map(String::as_str) { | |
| 200 | Some(action) => config::load(&folder).and_then(|config| service::main(action, &config, &folder)), | |
| 201 | None => Err(anyhow::anyhow!("service takes install, uninstall, start, stop or status")), | |
| 202 | }, | |
| 203 | "remove" => remove(&folder), | |
| 204 | "update" => config::load(&folder).and_then(|config| match update::update(&config)? { | |
| 205 | Some(version) => { | |
| 206 | log(&format!("Updated to {version}. Restart the runner (or its service) to use it.")); | |
| 207 | Ok(()) | |
| 208 | } | |
| 209 | None => { | |
| 210 | log(&format!("{VERSION} is the newest release.")); | |
| 211 | Ok(()) | |
| 212 | } | |
| 213 | }), | |
| 214 | "version" | "--version" => { | |
| 215 | println!("g1t-runner {VERSION} ({})", update::platform()); | |
| 216 | Ok(()) | |
| 217 | } | |
| 218 | _ => { | |
| 219 | println!("{HELP}"); | |
| 220 | Ok(()) | |
| 221 | } | |
| 222 | }; | |
| 223 | match outcome { | |
| 224 | Ok(()) => 0, | |
| 225 | Err(error) => { | |
| 226 | eprintln!("g1t-runner: {error:#}"); | |
| 227 | 1 | |
| 228 | } | |
| 229 | } | |
| 230 | } | |
| 231 | ||
| 232 | fn hostname() -> String { | |
| 233 | for name in ["COMPUTERNAME", "HOSTNAME"] { | |
| 234 | if let Ok(value) = std::env::var(name).map(|v| v.trim().to_owned()) | |
| 235 | && !value.is_empty() | |
| 236 | { | |
| 237 | return value; | |
| 238 | } | |
| 239 | } | |
| 240 | std::process::Command::new("hostname") | |
| 241 | .output() | |
| 242 | .ok() | |
| 243 | .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned()) | |
| 244 | .filter(|name| !name.is_empty()) | |
| 245 | .unwrap_or_else(|| "runner".to_owned()) | |
| 246 | } | |
| 247 | ||
| 248 | /// A runner's name from a machine's: what g1t accepts. | |
| 249 | fn runner_name(given: &str) -> String { | |
| 250 | let name: String = given | |
| 251 | .chars() | |
| 252 | .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }) | |
| 253 | .take(64) | |
| 254 | .collect(); | |
| 255 | if name.is_empty() { "runner".into() } else { name } | |
| 256 | } | |
| 257 | ||
| 258 | fn register(options: &Options, folder: &Path) -> Result<()> { | |
| 259 | let Some(url) = options.value("url") else { bail!("--url is needed: the g1t you register with, such as https://g1t.sh") }; | |
| 260 | let Some(token) = options.value("token") else { bail!("--token is needed: make a registration token under Settings, Runners") }; | |
| 261 | let url = url.trim_end_matches('/').to_owned(); | |
| 262 | let api = options.value("api").map(|a| a.trim_end_matches('/').to_owned()).unwrap_or_else(|| config::api_for(&url)); | |
| 263 | let name = runner_name(options.value("name").map(str::to_owned).unwrap_or_else(hostname).as_str()); | |
| 264 | let labels: Vec<String> = options | |
| 265 | .value("labels") | |
| 266 | .unwrap_or_default() | |
| 267 | .split(',') | |
| 268 | .map(|l| l.trim().to_owned()) | |
| 269 | .filter(|l| !l.is_empty()) | |
| 270 | .collect(); | |
| 271 | let docker = !options.flag("no-docker"); | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 272 | let isolation = isolation(docker)?; |
| 273 | if isolation == sandbox::Isolation::Host { | |
| 274 | log(sandbox::HOST_MODE_AT_START); | |
| 275 | } else if !sandbox::nested_requested() && !exec::docker_ready() { | |
| 276 | log("Docker does not answer here. Jobs will fail until it does."); | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 277 | } |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 278 | let pids_limit = match options.value("pids-limit") { |
| 279 | Some(text) => Some(text.parse::<u32>().ok().filter(|n| *n > 0).with_context(|| format!("--pids-limit takes a number of processes, not {text}"))?), | |
| 280 | None => None, | |
| 281 | }; | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 282 | let existing = config::load(folder).ok(); |
| 283 | if existing.is_some() && !options.flag("replace") { | |
| 284 | bail!("{} already holds a registered runner: run `g1t-runner remove` first, or register with --replace or another --dir", folder.display()); | |
| 285 | } | |
| 286 | // The OS jobs run on: in Docker that is Linux, whatever the machine | |
| 287 | // (Docker Desktop runs Linux containers on macOS and Windows). | |
| 288 | let os = match std::env::consts::OS { | |
| 289 | _ if docker => "linux", | |
| 290 | "macos" => "macos", | |
| 291 | "windows" => "windows", | |
| 292 | _ => "linux", | |
| 293 | }; | |
| 294 | let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" }; | |
| 295 | let registered = Api::register( | |
| 296 | &api, | |
| 297 | &json!({ | |
| 298 | "token": token, | |
| 299 | "name": name, | |
| 300 | "labels": labels, | |
| 301 | "os": os, | |
| 302 | "arch": arch, | |
| 303 | "version": VERSION, | |
| 304 | "ephemeral": options.flag("ephemeral"), | |
| 305 | "group": options.value("group"), | |
| 306 | "replace": options.flag("replace"), | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 307 | "isolation": isolation.as_str(), |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 308 | }), |
| 309 | )?; | |
| 310 | let work_dir = options.value("work-dir").map(PathBuf::from).unwrap_or_else(|| folder.join("work")); | |
| 311 | let config = Config { | |
| 312 | url, | |
| 313 | api, | |
| 314 | runner: registered.runner.id.clone(), | |
| 315 | name: registered.runner.name.clone(), | |
| 316 | credential: registered.credential, | |
| 317 | workspace: registered.runner.workspace.clone(), | |
| 318 | repo: registered.runner.repo.clone(), | |
| 319 | group: registered.runner.group.clone(), | |
| 320 | labels: registered.runner.labels.clone(), | |
| 321 | ephemeral: options.flag("ephemeral"), | |
| 322 | work_dir, | |
| 323 | docker, | |
| 324 | image: options.value("image").map(str::to_owned), | |
| 325 | agent_image: options.value("agent-image").map(str::to_owned), | |
| 326 | harness: options.value("harness").map(PathBuf::from), | |
| 327 | auto_update: !options.flag("no-auto-update"), | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 328 | memory: options.value("memory").map(str::to_owned), |
| 329 | cpus: options.value("cpus").map(str::to_owned), | |
| 330 | pids_limit, | |
| 331 | job_docker: !options.flag("no-job-docker"), | |
| 332 | job_docker_image: options.value("job-docker-image").map(str::to_owned), | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 333 | }; |
| 334 | config::save(folder, &config)?; | |
| 335 | let place = match &config.repo { | |
| 336 | Some(repo) => format!("for {repo}"), | |
| 337 | None => format!("in {}{}", config.workspace, config.group.as_ref().map(|g| format!(", group {g}")).unwrap_or_default()), | |
| 338 | }; | |
| 339 | log(&format!("Registered {} {place}, with labels {}.", config.name, config.labels.join(", "))); | |
| 340 | log("Start it with `g1t-runner run`, or `g1t-runner service install` to keep it running."); | |
| 341 | Ok(()) | |
| 342 | } | |
| 343 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 344 | /// How this runner keeps work from its machine; refuses a nested runner |
| 345 | /// that can reach the machine (sandbox.rs). | |
| 346 | fn isolation(docker: bool) -> Result<sandbox::Isolation> { | |
| 347 | use sandbox::{Isolation, SandboxDriver}; | |
| 348 | if !docker { | |
| 349 | // What is coming for jobs native to this OS. | |
| 350 | if let Some(driver) = sandbox::native_driver() | |
| 351 | && let Err(why) = driver.preflight() | |
| 352 | { | |
| 353 | log(&format!("Sandboxed {} jobs, in {}: {why}.", std::env::consts::OS, driver.name())); | |
| 354 | } | |
| 355 | return Ok(Isolation::Host); | |
| 356 | } | |
| 357 | if !sandbox::nested_requested() { | |
| 358 | return sandbox::SharedDocker.preflight(); | |
| 359 | } | |
| 360 | if !cfg!(target_os = "linux") { | |
| 361 | bail!("{}=nested is for the runner's Linux image", sandbox::SANDBOX_ENV); | |
| 362 | } | |
| 363 | let isolation = sandbox::NestedDocker { allow_unsafe: sandbox::unsafe_allowed() }.preflight()?; | |
| 364 | if isolation != Isolation::NestedContainer { | |
| 365 | for problem in sandbox::Findings::read().problems() { | |
| 366 | log(&format!("UNSAFE: {problem}")); | |
| 367 | } | |
| 368 | log(&format!("UNSAFE: {}=1 is set, so the runner starts anyway, and its jobs can reach this machine.", sandbox::UNSAFE_ENV)); | |
| 369 | } | |
| 370 | Ok(isolation) | |
| 371 | } | |
| 372 | ||
| 373 | /// A nested runner's own Docker Engine, which it uses whatever else it | |
| 374 | /// can see (with the override, it starts despite that). | |
| 375 | fn prepare(docker: bool) -> Result<()> { | |
| 376 | if !docker || !sandbox::nested_requested() { | |
| 377 | return Ok(()); | |
| 378 | } | |
| 379 | #[cfg(unix)] | |
| 380 | { | |
| 381 | let version = engine::start()?; | |
| 382 | log(&format!("Started this runner's own Docker Engine {version}, inside its container.")); | |
| 383 | } | |
| 384 | Ok(()) | |
| 385 | } | |
| 386 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 387 | fn remove(folder: &Path) -> Result<()> { |
| 388 | let config = config::load(folder)?; | |
| 389 | let api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() }; | |
| 390 | match api.remove() { | |
| 391 | Ok(()) | Err(Failure::Refused(401, _)) | Err(Failure::Refused(404, _)) => {} | |
| 392 | Err(failure) => bail!("{failure}; the runner is still registered"), | |
| 393 | } | |
| 394 | config::forget(folder); | |
| 395 | log(&format!("Removed {}.", config.name)); | |
| 396 | Ok(()) | |
| 397 | } | |
| 398 | ||
| 399 | /// The loop, for a Windows service: its exit code. | |
| 400 | #[cfg_attr(not(windows), allow(dead_code))] | |
| 401 | pub fn serve_from_service() -> i32 { | |
| 402 | let folder = config::folder(None); | |
| 403 | let folder = std::env::args() | |
| 404 | .collect::<Vec<_>>() | |
| 405 | .windows(2) | |
| 406 | .find(|pair| pair[0] == "--dir") | |
| 407 | .map(|pair| PathBuf::from(&pair[1])) | |
| 408 | .unwrap_or(folder); | |
| 409 | match run(&folder, false) { | |
| 410 | Ok(()) => 0, | |
| 411 | Err(error) => { | |
| 412 | eprintln!("g1t-runner: {error:#}"); | |
| 413 | 1 | |
| 414 | } | |
| 415 | } | |
| 416 | } | |
| 417 | ||
| 418 | /// Sleeps up to `duration`, waking early when `stop` says so. | |
| 419 | fn nap(duration: Duration, mut stop: impl FnMut() -> bool) { | |
| 420 | let until = Instant::now() + duration; | |
| 421 | while Instant::now() < until { | |
| 422 | if STOP.load(Ordering::SeqCst) || stop() { | |
| 423 | return; | |
| 424 | } | |
| 425 | std::thread::sleep(Duration::from_millis(250)); | |
| 426 | } | |
| 427 | } | |
| 428 | ||
| 429 | fn run(folder: &Path, once: bool) -> Result<()> { | |
| 430 | let mut config = config::load(folder)?; | |
| 431 | let mut api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() }; | |
| 432 | std::fs::create_dir_all(&config.work_dir).with_context(|| format!("could not make {}", config.work_dir.display()))?; | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 433 | let isolation = isolation(config.docker)?; |
| 434 | prepare(config.docker)?; | |
| 435 | if isolation != sandbox::Isolation::Host { | |
| 436 | exec::sweep(&config); | |
| 437 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 438 | log(&format!( |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 439 | "g1t-runner {VERSION}: {} ({}) is listening for work from {}. Isolation: {}.", |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 440 | config.name, |
| 441 | config.labels.join(", "), | |
| 442 | config.url, | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 443 | isolation.describe() |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 444 | )); |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 445 | if isolation == sandbox::Isolation::Host { |
| 446 | log(sandbox::HOST_MODE_AT_START); | |
| 447 | } | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 448 | let mut current: Option<exec::Running> = None; |
| 449 | let mut ran_one = false; | |
| 450 | let mut backoff = Duration::from_secs(2); | |
| 451 | let mut last_update_check = Instant::now().checked_sub(UPDATE_EVERY).unwrap_or_else(Instant::now); | |
| 452 | let mut said_no_key = false; | |
| 453 | loop { | |
| 454 | if STOP.load(Ordering::SeqCst) { | |
| 455 | if let Some(mut running) = current.take() { | |
| 456 | log(&format!("Stopping: {} is cut short.", running.name)); | |
| 457 | running.stop(); | |
| 458 | let _ = api.finished(&running.id, 1, Some("The self-hosted runner was stopped.")); | |
| 459 | running.clean(); | |
| 460 | } | |
| 461 | return Ok(()); | |
| 462 | } | |
| 463 | // What was running has ended: say how. | |
| 464 | if let Some(running) = current.as_mut() | |
| 465 | && let Some(code) = running.ended() | |
| 466 | { | |
| 467 | let mut running = current.take().expect("checked above"); | |
| 468 | log(&format!("{} {} ended ({code}).", running.kind, running.name)); | |
| 469 | if let Err(failure) = api.finished(&running.id, code, None) { | |
| 470 | log(&format!("Could not say how it ended: {failure}")); | |
| 471 | } | |
| 472 | running.clean(); | |
| 473 | ran_one = true; | |
| 474 | if config.ephemeral || once { | |
| 475 | if config.ephemeral { | |
| 476 | let _ = api.remove(); | |
| 477 | config::forget(folder); | |
| 478 | log("The ephemeral runner ran its job and removed itself."); | |
| 479 | } | |
| 480 | return Ok(()); | |
| 481 | } | |
| 482 | } | |
| 483 | // Idle: a newer release, now and then. | |
| 484 | if current.is_none() && config.auto_update && last_update_check.elapsed() >= UPDATE_EVERY { | |
| 485 | last_update_check = Instant::now(); | |
| 486 | if update::can_update() { | |
| 487 | match update::update(&config) { | |
| 488 | Ok(Some(version)) => { | |
| 489 | log(&format!("Updated to {version}; starting it.")); | |
| 490 | return restart(); | |
| 491 | } | |
| 492 | Ok(None) => {} | |
| 493 | Err(error) => log(&format!("Could not check for a new version: {error:#}")), | |
| 494 | } | |
| 495 | } else if !said_no_key { | |
| 496 | said_no_key = true; | |
| 497 | log("This build cannot check releases, so it will not update itself."); | |
| 498 | } | |
| 499 | } | |
| 500 | let running: Vec<String> = current.iter().map(|r| r.id.clone()).collect(); | |
| 501 | let wait = if current.is_some() { 0 } else { IDLE_WAIT_MS }; | |
| 502 | let poll = match api.poll(&running, wait) { | |
| 503 | Ok(poll) => { | |
| 504 | backoff = Duration::from_secs(2); | |
| 505 | poll | |
| 506 | } | |
| 507 | Err(Failure::Refused(401, message)) => { | |
| 508 | if let Some(mut running) = current.take() { | |
| 509 | running.stop(); | |
| 510 | running.clean(); | |
| 511 | } | |
| 512 | bail!("g1t no longer knows this runner ({message}). Register it again."); | |
| 513 | } | |
| 514 | Err(failure) => { | |
| 515 | log(&format!("{failure}; trying again in {}s", backoff.as_secs())); | |
| 516 | nap(backoff, || false); | |
| 517 | backoff = (backoff * 2).min(Duration::from_secs(60)); | |
| 518 | continue; | |
| 519 | } | |
| 520 | }; | |
| 521 | if let Some(fresh) = poll.credential { | |
| 522 | config.credential = fresh.clone(); | |
| 523 | api.credential = fresh; | |
| 524 | config::save(folder, &config)?; | |
| 525 | } | |
| 526 | if poll.removed { | |
| 527 | config::forget(folder); | |
| 528 | log("This runner was removed from g1t; stopping."); | |
| 529 | return Ok(()); | |
| 530 | } | |
| 531 | if let Some(running) = current.as_mut() | |
| 532 | && poll.cancel.contains(&running.id) | |
| 533 | { | |
| 534 | log(&format!("{} was cancelled; stopping it.", running.name)); | |
| 535 | running.stop(); | |
| 536 | } | |
| 537 | if let Some(running) = current.as_mut() | |
| 538 | && running.overdue() | |
| 539 | { | |
| 540 | log(&format!("{} ran past its time limit; stopping it.", running.name)); | |
| 541 | running.stop(); | |
| 542 | } | |
| 543 | if let Some(work) = poll.assignment { | |
| 544 | log(&format!("Took {} {} ({}).", work.kind, work.name, work.repo)); | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 545 | match exec::start(&config, isolation, folder, &work) { |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 546 | Ok(running) => current = Some(running), |
| 547 | Err(error) => { | |
| 548 | log(&format!("Could not start it: {error:#}")); | |
| 549 | let _ = api.finished(&work.id, 1, Some(&format!("The self-hosted runner {} could not start it: {error:#}", config.name))); | |
| 550 | if config.ephemeral || once { | |
| 551 | return Ok(()); | |
| 552 | } | |
| 553 | } | |
| 554 | } | |
| 555 | continue; | |
| 556 | } | |
| 557 | if once && ran_one { | |
| 558 | return Ok(()); | |
| 559 | } | |
| 560 | // Busy: a heartbeat every few seconds until it ends. | |
| 561 | if current.is_some() { | |
| 562 | let mut check = || current.as_mut().is_some_and(|r| r.ended().is_some()); | |
| 563 | nap(HEARTBEAT, &mut check); | |
| 564 | } | |
| 565 | } | |
| 566 | } | |
| 567 | ||
| 568 | /// Runs the updated program in this one's place, with the same arguments. | |
| 569 | fn restart() -> Result<()> { | |
| 570 | let me = std::env::current_exe()?; | |
| 571 | let status = std::process::Command::new(me).args(std::env::args().skip(1)).status()?; | |
| 572 | std::process::exit(status.code().unwrap_or(1)); | |
| 573 | } | |
| 574 | ||
| 575 | #[cfg(test)] | |
| 576 | mod tests { | |
| 577 | use super::*; | |
| 578 | ||
| 579 | fn args(items: &[&str]) -> Vec<String> { | |
| 580 | items.iter().map(|s| s.to_string()).collect() | |
| 581 | } | |
| 582 | ||
| 583 | #[test] | |
| 584 | fn commands_are_told_apart_from_the_harness() { | |
| 585 | assert!(is_command(&args(&["register", "--url", "x"]))); | |
| 586 | assert!(is_command(&args(&["run"]))); | |
| 587 | assert!(!is_command(&args(&[]))); | |
| 588 | assert!(!is_command(&args(&["something"]))); | |
| 589 | } | |
| 590 | ||
| 591 | #[test] | |
| 592 | fn options_read_both_ways() { | |
| 593 | let options = Options::parse(&args(&["--url", "https://g1t.sh", "--labels=gpu,cuda", "--ephemeral", "install"]), &["url", "labels"]).unwrap(); | |
| 594 | assert_eq!(options.value("url"), Some("https://g1t.sh")); | |
| 595 | assert_eq!(options.value("labels"), Some("gpu,cuda")); | |
| 596 | assert!(options.flag("ephemeral")); | |
| 597 | assert_eq!(options.rest, vec!["install".to_owned()]); | |
| 598 | assert!(Options::parse(&args(&["--url"]), &["url"]).is_err()); | |
| 599 | } | |
| 600 | ||
| 601 | #[test] | |
| 602 | fn a_machines_name_becomes_a_runners() { | |
| 603 | assert_eq!(runner_name("Build Box #1"), "Build-Box--1"); | |
| 604 | assert_eq!(runner_name(""), "runner"); | |
| 605 | assert_eq!(runner_name(&"x".repeat(80)).len(), 64); | |
| 606 | } | |
| 607 | ||
| 608 | #[test] | |
| 609 | fn the_log_says_when() { | |
| 610 | assert_eq!(g1t_now(1_790_918_179_123), "2026-10-02T05:16:19Z"); | |
| 611 | } | |
| 612 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.