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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 28 | #[cfg(unix)] |
| 29 | mod engine; | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 30 | mod exec; |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 443 | isolation.describe() |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 444 | )); |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 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.