Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

759 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] [--replace]
7//! [--memory 8g] [--cpus 4] [--pids-limit 4096] [--no-job-docker]
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.
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.
25
26mod api;
27mod config;
28#[cfg(unix)]
29mod engine;
30mod exec;
31mod launcher;
32mod native;
33mod needs;
34mod private;
35mod sandbox;
36mod service;
37mod update;
38
39use std::path::{Path, PathBuf};
40use std::sync::atomic::{AtomicBool, Ordering};
41use std::time::{Duration, Instant};
42
43use anyhow::{Context, Result, bail};
44use serde_json::json;
45
46use api::{Api, Failure};
47use config::Config;
48
49pub const VERSION: &str = env!("CARGO_PKG_VERSION");
50
51/// Set to stop the loop between polls: a Windows service's stop request.
52pub static STOP: AtomicBool = AtomicBool::new(false);
53
54/// How long a busy runner waits between heartbeats.
55const HEARTBEAT: Duration = Duration::from_secs(10);
56/// How long an idle poll waits for work.
57const IDLE_WAIT_MS: u64 = 20_000;
58/// How often an idle runner checks for a new version.
59const UPDATE_EVERY: Duration = Duration::from_secs(6 * 60 * 60);
60
61const COMMANDS: &[&str] = &["register", "register-and-run", "run", "service", "sandbox", "remove", "update", "version", "--version", "help", "--help", "-h"];
62
63/// Whether `args` (without the program) are a command of this one, rather
64/// than the harness's `MODE`.
65pub fn is_command(args: &[String]) -> bool {
66 args.first().is_some_and(|first| COMMANDS.contains(&first.as_str()))
67}
68
69pub fn log(message: &str) {
70 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
71 println!("{} {message}", g1t_now(now));
72}
73
74/// RFC 3339 for the log, without pulling in a date library.
75fn g1t_now(ms: u64) -> String {
76 let seconds = ms / 1000;
77 let (days, rest) = (seconds / 86_400, seconds % 86_400);
78 let z = days as i64 + 719_468;
79 let era = z.div_euclid(146_097);
80 let doe = z.rem_euclid(146_097);
81 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
82 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
83 let mp = (5 * doy + 2) / 153;
84 let day = doy - (153 * mp + 2) / 5 + 1;
85 let month = if mp < 10 { mp + 3 } else { mp - 9 };
86 let year = yoe + era * 400 + i64::from(month <= 2);
87 format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", rest / 3600, rest % 3600 / 60, rest % 60)
88}
89
90/// `--name value` and `--flag` options.
91struct Options {
92 values: Vec<(String, String)>,
93 flags: Vec<String>,
94 rest: Vec<String>,
95}
96
97impl Options {
98 fn parse(args: &[String], takes_value: &[&str]) -> Result<Options> {
99 let mut options = Options { values: Vec::new(), flags: Vec::new(), rest: Vec::new() };
100 let mut i = 0;
101 while i < args.len() {
102 let arg = &args[i];
103 if let Some(name) = arg.strip_prefix("--") {
104 if let Some((name, value)) = name.split_once('=') {
105 options.values.push((name.to_owned(), value.to_owned()));
106 } else if takes_value.contains(&name) {
107 let Some(value) = args.get(i + 1) else { bail!("--{name} needs a value") };
108 options.values.push((name.to_owned(), value.clone()));
109 i += 1;
110 } else {
111 options.flags.push(name.to_owned());
112 }
113 } else {
114 options.rest.push(arg.clone());
115 }
116 i += 1;
117 }
118 Ok(options)
119 }
120
121 fn value(&self, name: &str) -> Option<&str> {
122 self.values.iter().rev().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
123 }
124
125 fn flag(&self, name: &str) -> bool {
126 self.flags.iter().any(|f| f == name)
127 }
128
129 /// For `register-and-run` in a container: each option may come from
130 /// `G1T_RUNNER_<NAME>` instead (`G1T_RUNNER_TOKEN`, `G1T_RUNNER_LABELS`,
131 /// `G1T_RUNNER_EPHEMERAL=1`), so a Kubernetes secret can hold the token.
132 fn with_environment(mut self) -> Options {
133 let env_name = |name: &str| format!("G1T_RUNNER_{}", name.to_ascii_uppercase().replace('-', "_"));
134 for name in VALUE_OPTIONS {
135 if self.value(name).is_none()
136 && let Ok(value) = std::env::var(env_name(name))
137 && !value.is_empty()
138 {
139 self.values.push((name.to_owned(), value));
140 }
141 }
142 for name in ["ephemeral", "no-docker", "replace", "no-auto-update", "no-job-docker", "native"] {
143 if !self.flag(name) && std::env::var(env_name(name)).is_ok_and(|v| matches!(v.as_str(), "1" | "true" | "yes")) {
144 self.flags.push(name.to_owned());
145 }
146 }
147 self
148 }
149}
150
151/// The options that take a value.
152const VALUE_OPTIONS: [&str; 14] =
153 ["url", "api", "token", "name", "labels", "group", "work-dir", "image", "harness", "memory", "cpus", "pids-limit", "job-docker-image", "machine"];
154
155const HELP: &str = "g1t-runner: a self-hosted runner for g1t.
156
157 g1t-runner register --url https://g1t.sh --token <registration token>
158 [--name NAME] [--labels a,b] [--group GROUP] [--ephemeral]
159 [--work-dir DIR] [--no-docker] [--image IMAGE]
160 [--api URL] [--harness PATH] [--replace] [--no-auto-update]
161 [--memory 8g] [--cpus 4] [--pids-limit 4096] [--no-job-docker]
162 [--job-docker-image IMAGE] [--native]
163 g1t-runner run [--once] poll for work and run it
164 g1t-runner sandbox up|down|status|update [--json]
165 on your own computer: a Linux VM of the
166 runner's own for Linux jobs, and Windows
167 Sandbox for Windows jobs; see
168 `g1t-runner sandbox help`
169 g1t-runner register-and-run ... both, for containers; options may be
170 G1T_RUNNER_URL, _TOKEN, _LABELS, _EPHEMERAL…
171 g1t-runner service install|start|stop|status|uninstall
172 g1t-runner remove unregister this runner
173 g1t-runner update update to the newest release
174 g1t-runner version
175
176In a workflow step (the g1t/deploy and g1t/detect actions use these):
177 g1t-runner deploy build this checkout and put it on g1t.page
178 g1t-runner detect [--root-dir DIR] what that build would find
179
180Every command takes --dir DIR (default ~/.g1t-runner, or G1T_RUNNER_DIR).
181Run it as a container under Sysbox so neither it nor its jobs reach the
182machine: docker run --runtime=sysbox-runc … g1t.sh/flagon-io/g1t-runner,
183or on a desktop, g1t-runner sandbox up. --native runs jobs native to this
184OS, each in a throwaway VM (Windows Sandbox). --no-docker (host mode) is
185deprecated.
186Make a registration token under Settings, Runners on g1t.sh.
187Docs: https://docs.g1t.sh/guides/self-hosted-runners/";
188
189/// Runs a command. Returns the process's exit code.
190pub fn main(args: Vec<String>) -> i32 {
191 // Options of its own, and output the desktop app reads.
192 if args[0] == "sandbox" {
193 return launcher::main(&args[1..]);
194 }
195 let mut takes = VALUE_OPTIONS.to_vec();
196 takes.push("dir");
197 let options = match Options::parse(&args[1..], &takes) {
198 Ok(options) => options,
199 Err(error) => {
200 eprintln!("g1t-runner: {error:#}");
201 return 2;
202 }
203 };
204 let folder = config::folder(options.value("dir"));
205 let outcome = match args[0].as_str() {
206 "register" => register(&options, &folder),
207 // A container's one command: register unless it already is, then run.
208 "register-and-run" => {
209 let options = options.with_environment();
210 let ready = if config::load(&folder).is_ok() { Ok(()) } else { register(&options, &folder) };
211 ready.and_then(|()| run(&folder, options.flag("once")))
212 }
213 "run" => run(&folder, options.flag("once")),
214 "service" => match options.rest.first().map(String::as_str) {
215 Some(action) => config::load(&folder).and_then(|config| service::main(action, &config, &folder)),
216 None => Err(anyhow::anyhow!("service takes install, uninstall, start, stop or status")),
217 },
218 "remove" => remove(&folder),
219 "update" => config::load(&folder).and_then(|config| match update::update(&config)? {
220 Some(version) => {
221 log(&format!("Updated to {version}. Restart the runner (or its service) to use it."));
222 Ok(())
223 }
224 None => {
225 log(&format!("{VERSION} is the newest release."));
226 Ok(())
227 }
228 }),
229 "version" | "--version" => {
230 println!("g1t-runner {VERSION} ({})", update::platform());
231 Ok(())
232 }
233 _ => {
234 println!("{HELP}");
235 Ok(())
236 }
237 };
238 match outcome {
239 Ok(()) => 0,
240 Err(error) => {
241 eprintln!("g1t-runner: {error:#}");
242 1
243 }
244 }
245}
246
247fn hostname() -> String {
248 for name in ["COMPUTERNAME", "HOSTNAME"] {
249 if let Ok(value) = std::env::var(name).map(|v| v.trim().to_owned())
250 && !value.is_empty()
251 {
252 return value;
253 }
254 }
255 std::process::Command::new("hostname")
256 .output()
257 .ok()
258 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
259 .filter(|name| !name.is_empty())
260 .unwrap_or_else(|| "runner".to_owned())
261}
262
263/// Which computer this is, for g1t to know it when it registers again (its
264/// sandbox made again, its state lost) and let it take its runners' names
265/// back: a hash of the operating system's machine id, never the id itself.
266/// None in a container, whose machine id is its image's and so shared by
267/// every container made from it (the sandbox's launcher passes the
268/// computer's as `G1T_RUNNER_MACHINE`), and where the OS has none.
269pub fn machine_fingerprint() -> Option<String> {
270 use sha2::{Digest, Sha256};
271 let id = os_machine_id()?;
272 Some(hex::encode(Sha256::digest(format!("g1t-runner machine\n{id}"))))
273}
274
275fn os_machine_id() -> Option<String> {
276 let id = if cfg!(windows) {
277 // HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid, set when Windows is installed.
278 let out = hidden(std::process::Command::new("reg").args(["query", r"HKLM\SOFTWARE\Microsoft\Cryptography", "/v", "MachineGuid"])).output().ok()?;
279 String::from_utf8_lossy(&out.stdout).lines().find(|l| l.contains("MachineGuid"))?.split_whitespace().last()?.to_owned()
280 } else if cfg!(target_os = "macos") {
281 let out = std::process::Command::new("ioreg").args(["-rd1", "-c", "IOPlatformExpertDevice"]).output().ok()?;
282 let text = String::from_utf8_lossy(&out.stdout).into_owned();
283 text.lines().find(|l| l.contains("\"IOPlatformUUID\""))?.rsplit('"').nth(1)?.to_owned()
284 } else {
285 if Path::new("/.dockerenv").exists() || Path::new("/run/.containerenv").exists() {
286 return None;
287 }
288 ["/etc/machine-id", "/var/lib/dbus/machine-id"].iter().find_map(|path| std::fs::read_to_string(path).ok())?
289 };
290 let id = id.trim().to_ascii_lowercase();
291 (id.len() >= 8 && id.chars().any(|c| c != '0' && c != '-')).then_some(id)
292}
293
294/// No console window for a helper the runner runs on Windows.
295fn hidden(command: &mut std::process::Command) -> &mut std::process::Command {
296 #[cfg(windows)]
297 {
298 use std::os::windows::process::CommandExt;
299 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
300 command.creation_flags(CREATE_NO_WINDOW);
301 }
302 command
303}
304
305/// A runner's name from a machine's: what g1t accepts.
306fn runner_name(given: &str) -> String {
307 let name: String = given
308 .chars()
309 .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' })
310 .take(64)
311 .collect();
312 if name.is_empty() { "runner".into() } else { name }
313}
314
315fn register(options: &Options, folder: &Path) -> Result<()> {
316 let Some(url) = options.value("url") else { bail!("--url is needed: the g1t you register with, such as https://g1t.sh") };
317 let Some(token) = options.value("token") else { bail!("--token is needed: make a registration token under Settings, Runners") };
318 let url = url.trim_end_matches('/').to_owned();
319 let api = options.value("api").map(|a| a.trim_end_matches('/').to_owned()).unwrap_or_else(|| config::api_for(&url));
320 let name = runner_name(options.value("name").map(str::to_owned).unwrap_or_else(hostname).as_str());
321 let labels: Vec<String> = options
322 .value("labels")
323 .unwrap_or_default()
324 .split(',')
325 .map(|l| l.trim().to_owned())
326 .filter(|l| !l.is_empty())
327 .collect();
328 let native = options.flag("native");
329 if native && options.flag("no-docker") {
330 bail!("--native runs each job in a VM of its own, and --no-docker runs it on this machine: pick one");
331 }
332 let docker = !options.flag("no-docker") && !native;
333 let isolation = isolation(docker, native)?;
334 if isolation == sandbox::Isolation::Host {
335 log(sandbox::HOST_MODE_AT_START);
336 } else if !native && !sandbox::nested_requested() && !exec::docker_ready() {
337 log("Docker does not answer here. Jobs will fail until it does.");
338 }
339 let pids_limit = match options.value("pids-limit") {
340 Some(text) => Some(text.parse::<u32>().ok().filter(|n| *n > 0).with_context(|| format!("--pids-limit takes a number of processes, not {text}"))?),
341 None => None,
342 };
343 let existing = config::load(folder).ok();
344 if existing.is_some() && !options.flag("replace") {
345 bail!("{} already holds a registered runner: run `g1t-runner remove` first, or register with --replace or another --dir", folder.display());
346 }
347 // The OS jobs run on: in Docker that is Linux, whatever the machine
348 // (Docker Desktop runs Linux containers on macOS and Windows). A
349 // runner says it runs jobs native to this OS only when it sandboxes
350 // them (--native), or when the person chose host mode (--no-docker).
351 let os = match std::env::consts::OS {
352 _ if docker => "linux",
353 "macos" => "macos",
354 "windows" => "windows",
355 _ => "linux",
356 };
357 let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" };
358 let registered = Api::register(
359 &api,
360 &json!({
361 "token": token,
362 "name": name,
363 "labels": labels,
364 "os": os,
365 "arch": arch,
366 "version": VERSION,
367 "ephemeral": options.flag("ephemeral"),
368 "group": options.value("group"),
369 "replace": options.flag("replace"),
370 "isolation": isolation.as_str(),
371 "machine": options.value("machine").map(str::to_owned).or_else(machine_fingerprint),
372 }),
373 )?;
374 let work_dir = options.value("work-dir").map(PathBuf::from).unwrap_or_else(|| folder.join("work"));
375 let config = Config {
376 url,
377 api,
378 runner: registered.runner.id.clone(),
379 name: registered.runner.name.clone(),
380 credential: registered.credential,
381 workspace: registered.runner.workspace.clone(),
382 repo: registered.runner.repo.clone(),
383 group: registered.runner.group.clone(),
384 labels: registered.runner.labels.clone(),
385 ephemeral: options.flag("ephemeral"),
386 work_dir,
387 docker,
388 image: options.value("image").map(str::to_owned),
389 harness: options.value("harness").map(PathBuf::from),
390 auto_update: !options.flag("no-auto-update"),
391 memory: options.value("memory").map(str::to_owned),
392 cpus: options.value("cpus").map(str::to_owned),
393 pids_limit,
394 job_docker: !options.flag("no-job-docker"),
395 job_docker_image: options.value("job-docker-image").map(str::to_owned),
396 native,
397 };
398 config::save(folder, &config)?;
399 let place = match &config.repo {
400 Some(repo) => format!("for {repo}"),
401 None => format!("in {}{}", config.workspace, config.group.as_ref().map(|g| format!(", group {g}")).unwrap_or_default()),
402 };
403 log(&format!("Registered {} {place}, with labels {}.", config.name, config.labels.join(", ")));
404 log("Start it with `g1t-runner run`, or `g1t-runner service install` to keep it running.");
405 Ok(())
406}
407
408/// How this runner keeps work from its machine; refuses a nested runner
409/// that can reach the machine (sandbox.rs).
410fn isolation(docker: bool, native: bool) -> Result<sandbox::Isolation> {
411 use sandbox::{Isolation, SandboxDriver};
412 if native {
413 let Some(driver) = sandbox::native_driver() else {
414 bail!("--native: there is no sandbox for jobs native to {}; Linux jobs run in containers", std::env::consts::OS);
415 };
416 return driver.preflight().with_context(|| format!("--native needs {}", driver.name()));
417 }
418 if !docker {
419 // What would sandbox jobs native to this OS instead.
420 if let Some(driver) = sandbox::native_driver() {
421 match driver.preflight() {
422 Ok(_) => log(&format!("This machine can run {} jobs sandboxed, each in {}: register with --native instead of --no-docker.", std::env::consts::OS, driver.name())),
423 Err(why) => log(&format!("Sandboxed {} jobs, in {}: {why:#}", std::env::consts::OS, driver.name())),
424 }
425 }
426 return Ok(Isolation::Host);
427 }
428 if !sandbox::nested_requested() {
429 return sandbox::SharedDocker.preflight();
430 }
431 if !cfg!(target_os = "linux") {
432 bail!("{}=nested is for the runner's Linux image", sandbox::SANDBOX_ENV);
433 }
434 let isolation = sandbox::NestedDocker { allow_unsafe: sandbox::unsafe_allowed() }.preflight()?;
435 if isolation != Isolation::NestedContainer {
436 for problem in sandbox::Findings::read().problems() {
437 log(&format!("UNSAFE: {problem}"));
438 }
439 log(&format!("UNSAFE: {}=1 is set, so the runner starts anyway, and its jobs can reach this machine.", sandbox::UNSAFE_ENV));
440 }
441 Ok(isolation)
442}
443
444/// A nested runner's own Docker Engine, which it uses whatever else it
445/// can see (with the override, it starts despite that).
446fn prepare(docker: bool) -> Result<()> {
447 if !docker || !sandbox::nested_requested() {
448 return Ok(());
449 }
450 #[cfg(unix)]
451 {
452 let version = engine::start()?;
453 log(&format!("Started this runner's own Docker Engine {version}, inside its container."));
454 }
455 Ok(())
456}
457
458fn remove(folder: &Path) -> Result<()> {
459 let config = config::load(folder)?;
460 let api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() };
461 match api.remove() {
462 Ok(()) | Err(Failure::Refused(401, _)) | Err(Failure::Refused(404, _)) => {}
463 Err(failure) => bail!("{failure}; the runner is still registered"),
464 }
465 config::forget(folder);
466 log(&format!("Removed {}.", config.name));
467 Ok(())
468}
469
470/// The loop, for a Windows service: its exit code.
471#[cfg_attr(not(windows), allow(dead_code))]
472pub fn serve_from_service() -> i32 {
473 let folder = config::folder(None);
474 let folder = std::env::args()
475 .collect::<Vec<_>>()
476 .windows(2)
477 .find(|pair| pair[0] == "--dir")
478 .map(|pair| PathBuf::from(&pair[1]))
479 .unwrap_or(folder);
480 match run(&folder, false) {
481 Ok(()) => 0,
482 Err(error) => {
483 eprintln!("g1t-runner: {error:#}");
484 1
485 }
486 }
487}
488
489/// Sleeps up to `duration`, waking early when `stop` says so.
490fn nap(duration: Duration, mut stop: impl FnMut() -> bool) {
491 let until = Instant::now() + duration;
492 while Instant::now() < until {
493 if STOP.load(Ordering::SeqCst) || stop() {
494 return;
495 }
496 std::thread::sleep(Duration::from_millis(250));
497 }
498}
499
500fn run(folder: &Path, once: bool) -> Result<()> {
501 // Its folder holds its credential and, while a Windows job runs, that
502 // job's script: a runner whose folder others can read does not start.
503 let mut config = config::load(folder)?;
504 private::folder(folder, "--dir")?;
505 let mut api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() };
506 std::fs::create_dir_all(&config.work_dir).with_context(|| format!("could not make {}", config.work_dir.display()))?;
507 let isolation = isolation(config.docker, config.native)?;
508 prepare(config.docker)?;
509 match isolation {
510 sandbox::Isolation::Host => {}
511 sandbox::Isolation::Vm => native::sweep(folder),
512 _ => exec::sweep(&config),
513 }
514 // A drain or stop asked for before this start is done with.
515 let _ = std::fs::remove_file(drain_path(folder));
516 let _ = std::fs::remove_file(folder.join("stop"));
517 log(&format!(
518 "g1t-runner {VERSION}: {} ({}) is listening for work from {}. Isolation: {}.",
519 config.name,
520 config.labels.join(", "),
521 config.url,
522 isolation.describe()
523 ));
524 if isolation == sandbox::Isolation::Host {
525 log(sandbox::HOST_MODE_AT_START);
526 }
527 let mut current: Option<exec::Running> = None;
528 let mut ran_one = false;
529 let mut backoff = Duration::from_secs(2);
530 let mut last_update_check = Instant::now().checked_sub(UPDATE_EVERY).unwrap_or_else(Instant::now);
531 let mut said_no_key = false;
532 loop {
533 // Asked to stop now (`g1t-runner sandbox down`): as a service's stop.
534 if std::fs::remove_file(folder.join("stop")).is_ok() {
535 STOP.store(true, Ordering::SeqCst);
536 }
537 if STOP.load(Ordering::SeqCst) {
538 if let Some(mut running) = current.take() {
539 log(&format!("Stopping: {} is cut short.", running.name));
540 running.stop();
541 let _ = api.finished(&running.id, 1, Some("The self-hosted runner was stopped."));
542 running.clean();
543 }
544 say_status(folder, isolation, None, "stopped");
545 return Ok(());
546 }
547 // What was running has ended: say how.
548 if let Some(running) = current.as_mut()
549 && let Some(code) = running.ended()
550 {
551 let mut running = current.take().expect("checked above");
552 log(&format!("{} {} ended ({code}).", running.kind, running.name));
553 if let Err(failure) = api.finished(&running.id, code, None) {
554 log(&format!("Could not say how it ended: {failure}"));
555 }
556 running.clean();
557 ran_one = true;
558 if config.ephemeral || once {
559 if config.ephemeral {
560 let _ = api.remove();
561 config::forget(folder);
562 log("The ephemeral runner ran its job and removed itself.");
563 }
564 return Ok(());
565 }
566 }
567 // Asked to drain (`g1t-runner sandbox update`, before it swaps the
568 // sandbox's image): no new work, and once idle, stop.
569 let draining = drain_path(folder).exists();
570 if draining && current.is_none() {
571 let _ = std::fs::remove_file(drain_path(folder));
572 say_status(folder, isolation, None, "stopped");
573 log("Drained: no job is running, so the runner stops here.");
574 return Ok(());
575 }
576 let state = match (&current, draining) {
577 (Some(_), true) => "draining",
578 (Some(_), false) => "busy",
579 (None, _) => "idle",
580 };
581 say_status(folder, isolation, current.as_ref(), state);
582 // Idle: a newer release, now and then.
583 if current.is_none() && config.auto_update && last_update_check.elapsed() >= UPDATE_EVERY {
584 last_update_check = Instant::now();
585 if update::can_update() {
586 match update::update(&config) {
587 Ok(Some(version)) => {
588 log(&format!("Updated to {version}; starting it."));
589 return restart();
590 }
591 Ok(None) => {}
592 Err(error) => log(&format!("Could not check for a new version: {error:#}")),
593 }
594 } else if !said_no_key {
595 said_no_key = true;
596 log("This build cannot check releases, so it will not update itself.");
597 }
598 }
599 let running: Vec<String> = current.iter().map(|r| r.id.clone()).collect();
600 let wait = if current.is_some() { 0 } else { IDLE_WAIT_MS };
601 let poll = match api.poll(&running, wait, isolation.as_str()) {
602 Ok(poll) => {
603 backoff = Duration::from_secs(2);
604 poll
605 }
606 Err(Failure::Refused(401, message)) => {
607 if let Some(mut running) = current.take() {
608 running.stop();
609 running.clean();
610 }
611 bail!("g1t no longer knows this runner ({message}). Register it again.");
612 }
613 Err(failure) => {
614 log(&format!("{failure}; trying again in {}s", backoff.as_secs()));
615 nap(backoff, || false);
616 backoff = (backoff * 2).min(Duration::from_secs(60));
617 continue;
618 }
619 };
620 if let Some(fresh) = poll.credential {
621 config.credential = fresh.clone();
622 api.credential = fresh;
623 config::save(folder, &config)?;
624 }
625 if poll.removed {
626 config::forget(folder);
627 log("This runner was removed from g1t; stopping.");
628 return Ok(());
629 }
630 if let Some(running) = current.as_mut()
631 && poll.cancel.contains(&running.id)
632 {
633 log(&format!("{} was cancelled; stopping it.", running.name));
634 running.stop();
635 }
636 if let Some(running) = current.as_mut()
637 && running.overdue()
638 {
639 log(&format!("{} ran past its time limit; stopping it.", running.name));
640 running.stop();
641 }
642 if let Some(work) = poll.assignment {
643 log(&format!("Took {} {} ({}).", work.kind, work.name, work.repo));
644 match exec::start(&config, isolation, folder, &work) {
645 Ok(running) => current = Some(running),
646 Err(error) => {
647 log(&format!("Could not start it: {error:#}"));
648 let _ = api.finished(&work.id, 1, Some(&format!("The self-hosted runner {} could not start it: {error:#}", config.name)));
649 if config.ephemeral || once {
650 return Ok(());
651 }
652 }
653 }
654 continue;
655 }
656 if once && ran_one {
657 return Ok(());
658 }
659 // Busy: a heartbeat every few seconds until it ends.
660 if current.is_some() {
661 let mut check = || current.as_mut().is_some_and(|r| r.ended().is_some());
662 nap(HEARTBEAT, &mut check);
663 }
664 }
665}
666
667/// The file whose presence asks a runner to drain: take no new work, and
668/// stop once idle.
669pub fn drain_path(folder: &Path) -> PathBuf {
670 folder.join("drain")
671}
672
673/// The file a runner keeps saying what it is doing, for `g1t-runner
674/// sandbox status` (which reads it from inside the sandbox).
675pub fn status_path(folder: &Path) -> PathBuf {
676 folder.join("status.json")
677}
678
679/// What a runner says it is doing, in its status file.
680#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize, PartialEq)]
681pub struct RunnerStatus {
682 pub version: String,
683 pub isolation: String,
684 /// `idle`, `busy`, `draining` or `stopped`.
685 pub state: String,
686 pub job: Option<JobStatus>,
687 pub updated_at: String,
688}
689
690#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize, PartialEq)]
691pub struct JobStatus {
692 /// `workflow`, or `agent` for g1t's own work (a merge check or a
693 /// state of the merge queue).
694 pub kind: String,
695 pub id: String,
696 pub name: String,
697 pub repo: String,
698}
699
700fn say_status(folder: &Path, isolation: sandbox::Isolation, running: Option<&exec::Running>, state: &str) {
701 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
702 let status = RunnerStatus {
703 version: VERSION.to_owned(),
704 isolation: isolation.as_str().to_owned(),
705 state: state.to_owned(),
706 job: running.map(|r| JobStatus { kind: r.kind.clone(), id: r.id.clone(), name: r.name.clone(), repo: r.repo.clone() }),
707 updated_at: g1t_now(now),
708 };
709 let temp = folder.join("status.json.new");
710 if serde_json::to_vec_pretty(&status).ok().is_some_and(|bytes| std::fs::write(&temp, bytes).is_ok()) {
711 let _ = std::fs::rename(&temp, status_path(folder));
712 }
713}
714
715/// Runs the updated program in this one's place, with the same arguments.
716fn restart() -> Result<()> {
717 let me = std::env::current_exe()?;
718 let status = std::process::Command::new(me).args(std::env::args().skip(1)).status()?;
719 std::process::exit(status.code().unwrap_or(1));
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 fn args(items: &[&str]) -> Vec<String> {
727 items.iter().map(|s| s.to_string()).collect()
728 }
729
730 #[test]
731 fn commands_are_told_apart_from_the_harness() {
732 assert!(is_command(&args(&["register", "--url", "x"])));
733 assert!(is_command(&args(&["run"])));
734 assert!(!is_command(&args(&[])));
735 assert!(!is_command(&args(&["something"])));
736 }
737
738 #[test]
739 fn options_read_both_ways() {
740 let options = Options::parse(&args(&["--url", "https://g1t.sh", "--labels=gpu,cuda", "--ephemeral", "install"]), &["url", "labels"]).unwrap();
741 assert_eq!(options.value("url"), Some("https://g1t.sh"));
742 assert_eq!(options.value("labels"), Some("gpu,cuda"));
743 assert!(options.flag("ephemeral"));
744 assert_eq!(options.rest, vec!["install".to_owned()]);
745 assert!(Options::parse(&args(&["--url"]), &["url"]).is_err());
746 }
747
748 #[test]
749 fn a_machines_name_becomes_a_runners() {
750 assert_eq!(runner_name("Build Box #1"), "Build-Box--1");
751 assert_eq!(runner_name(""), "runner");
752 assert_eq!(runner_name(&"x".repeat(80)).len(), 64);
753 }
754
755 #[test]
756 fn the_log_says_when() {
757 assert_eq!(g1t_now(1_790_918_179_123), "2026-10-02T05:16:19Z");
758 }
759}