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