Skip to content
270 linesCodeBlameRaw

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.

Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1//! Releases: where `g1t-runner` is published, how it updates itself, and
2//! the Linux build it runs inside containers.
3//!
4//! Every release is at `<site>/downloads/runner/<version>/`: one binary per
5//! platform (`g1t-runner-linux-x64`, `-linux-arm64`, `-macos-arm64`,
6//! `-macos-x64`, `-windows-x64.exe`), `SHA256SUMS`, and `manifest.json`.
7//! `<site>/downloads/runner/latest.json` is the newest release's manifest,
8//! and `latest.json.sig` its Ed25519 signature, made with g1t's release key
9//! (`scripts/runner-release.mjs`). A runner only trusts a manifest whose
10//! signature checks out against the key built into it, and only runs a
11//! binary whose SHA-256 is the manifest's.
12
13use std::path::{Path, PathBuf};
14
15use anyhow::{Context, Result, anyhow, bail};
16use base64::Engine;
17use base64::engine::general_purpose::STANDARD;
18use serde::Deserialize;
19use sha2::{Digest, Sha256};
20
21use super::api::download;
22use super::config::Config;
23use super::{VERSION, log};
24
25/// g1t's release key, as base64 of its 32 bytes: set when release builds
26/// are made. Without it a runner never updates itself and only takes the
27/// Linux harness from the release when its SHA-256 matches the unsigned
28/// manifest it fetched over HTTPS.
29const RELEASE_KEY: Option<&str> = option_env!("G1T_RUNNER_RELEASE_KEY");
30
31#[derive(Clone, Debug, Deserialize)]
32pub struct File {
33 pub name: String,
34 pub sha256: String,
35}
36
37#[derive(Clone, Debug, Deserialize)]
38pub struct Manifest {
39 pub version: String,
40 /// The image agent work runs in.
41 #[serde(default)]
42 pub agent_image: Option<String>,
43 /// By platform: `linux-x64`, `macos-arm64`, `windows-x64`…
44 pub files: std::collections::BTreeMap<String, File>,
Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it.45 /// The sandbox's images (`g1t-runner sandbox`, launcher/), signed with
46 /// the rest. None in releases from before it.
47 #[serde(default)]
48 pub sandbox: Option<SandboxRelease>,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays49}
50
Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it.51/// What `g1t-runner sandbox` runs, in a release.
52#[derive(Clone, Debug, Default, Deserialize)]
53pub struct SandboxRelease {
54 /// The runner's image inside the sandbox, by digest
55 /// (`g1t.sh/flagon-io/g1t-runner@sha256:…`), so pulling it checks it.
56 #[serde(default)]
57 pub runner_image: Option<String>,
58 /// By kind and architecture: `wsl-x64` (a root filesystem, `.tar.gz`,
59 /// for a WSL distribution), `vm-arm64-kernel`, `vm-arm64-initrd`,
60 /// `vm-arm64-disk` (a VM's boot files and its system disk, `.gz`).
61 #[serde(default)]
62 pub files: std::collections::BTreeMap<String, SandboxFile>,
63}
64
65#[derive(Clone, Debug, Deserialize)]
66pub struct SandboxFile {
67 pub name: String,
68 pub sha256: String,
69 #[serde(default)]
70 pub size: u64,
71}
72
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays73/// This machine's platform, as releases name it.
74pub fn platform() -> String {
75 let os = match std::env::consts::OS {
76 "macos" => "macos",
77 "windows" => "windows",
78 _ => "linux",
79 };
80 let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" };
81 format!("{os}-{arch}")
82}
83
84fn downloads(config: &Config) -> String {
Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it.85 downloads_at(&config.url)
86}
87
88/// Where a site keeps the runner's releases.
89pub fn downloads_at(site: &str) -> String {
90 format!("{}/downloads/runner", site.trim_end_matches('/'))
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays91}
92
93/// `a.b.c` newer than `x.y.z`.
94pub fn newer(candidate: &str, current: &str) -> bool {
95 let parts = |v: &str| -> Vec<u64> { v.trim_start_matches('v').split(['.', '-']).take(3).map(|p| p.parse().unwrap_or(0)).collect() };
96 parts(candidate) > parts(current)
97}
98
99pub fn sha256(bytes: &[u8]) -> String {
100 hex::encode(Sha256::digest(bytes))
101}
102
103/// Whether `signature` (base64) is g1t's release key's over `message`.
104pub fn verify(message: &[u8], signature: &str, key: &str) -> Result<()> {
105 let key: [u8; 32] = STANDARD
106 .decode(key.trim())
107 .ok()
108 .and_then(|bytes| bytes.try_into().ok())
109 .ok_or_else(|| anyhow!("the release key built into this runner does not read"))?;
110 let signature: [u8; 64] = STANDARD
111 .decode(signature.trim())
112 .ok()
113 .and_then(|bytes| bytes.try_into().ok())
114 .ok_or_else(|| anyhow!("the release's signature does not read"))?;
115 let key = ed25519_dalek::VerifyingKey::from_bytes(&key).map_err(|_| anyhow!("the release key is not a key"))?;
116 key.verify_strict(message, &ed25519_dalek::Signature::from_bytes(&signature))
117 .map_err(|_| anyhow!("the release's signature is not g1t's"))
118}
119
120/// The newest release, signed. `None` when this build has no release key.
121fn latest_signed(config: &Config) -> Result<Option<Manifest>> {
Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it.122 latest_signed_at(&config.url)
123}
124
125/// The newest release of `site`, signed. `None` when this build has no
126/// release key.
127pub fn latest_signed_at(site: &str) -> Result<Option<Manifest>> {
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays128 let Some(key) = RELEASE_KEY else { return Ok(None) };
Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it.129 let base = downloads_at(site);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays130 let manifest = download(&format!("{base}/latest.json"))?;
131 let signature = String::from_utf8(download(&format!("{base}/latest.json.sig"))?)?;
132 verify(&manifest, &signature, key)?;
133 Ok(Some(serde_json::from_slice(&manifest)?))
134}
135
136/// The manifest of a version: signed when this build can check, otherwise
137/// as fetched over HTTPS.
138fn manifest_of(config: &Config, version: &str) -> Result<Manifest> {
139 if let Some(latest) = latest_signed(config)?.filter(|m| m.version == version) {
140 return Ok(latest);
141 }
142 let bytes = download(&format!("{}/{version}/manifest.json", downloads(config)))?;
143 Ok(serde_json::from_slice(&bytes)?)
144}
145
146/// The image agent work runs in, from the release.
147pub fn agent_image(config: &Config) -> Option<String> {
148 manifest_of(config, VERSION).ok().and_then(|m| m.agent_image)
149}
150
151/// A Linux build of the harness to mount into containers: `--harness`;
152/// this program, on Linux; else the release's for this version, fetched
153/// once and checked.
154pub fn linux_harness(config: &Config, folder: &Path) -> Result<PathBuf> {
155 if let Some(path) = &config.harness {
156 return Ok(path.clone());
157 }
158 if cfg!(target_os = "linux") {
159 return std::env::current_exe().context("could not find this program");
160 }
161 let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" };
162 let path = folder.join("harness").join(VERSION).join("g1t-runner");
163 if path.exists() {
164 return Ok(path);
165 }
166 let manifest = manifest_of(config, VERSION)?;
167 let file = manifest
168 .files
169 .get(&format!("linux-{arch}"))
170 .ok_or_else(|| anyhow!("release {VERSION} has no Linux build for {arch}"))?;
171 log(&format!("Fetching the Linux harness for containers ({})", file.name));
172 let bytes = download(&format!("{}/{VERSION}/{}", downloads(config), file.name))?;
173 if sha256(&bytes) != file.sha256 {
174 bail!("the Linux harness's SHA-256 is not the release's; not using it");
175 }
176 std::fs::create_dir_all(path.parent().unwrap_or(folder))?;
177 std::fs::write(&path, bytes)?;
178 Ok(path)
179}
180
181/// Updates this program to the newest release, if there is one. Returns
182/// the new version when it did.
183pub fn update(config: &Config) -> Result<Option<String>> {
184 let Some(latest) = latest_signed(config)? else {
185 bail!("this build of g1t-runner has no release key, so it cannot check releases; download a release from {}", downloads(config));
186 };
187 if !newer(&latest.version, VERSION) {
188 return Ok(None);
189 }
190 let file = latest
191 .files
192 .get(&platform())
193 .ok_or_else(|| anyhow!("release {} has no build for {}", latest.version, platform()))?;
194 let bytes = download(&format!("{}/{}/{}", downloads(config), latest.version, file.name))?;
195 if sha256(&bytes) != file.sha256 {
196 bail!("the download's SHA-256 is not the release's; not updating");
197 }
198 replace_self(&bytes)?;
199 Ok(Some(latest.version))
200}
201
202/// Puts `bytes` where this program is. The running file is moved aside
203/// first: Windows will not overwrite a running program, but lets it be
204/// renamed.
205fn replace_self(bytes: &[u8]) -> Result<()> {
206 let me = std::env::current_exe()?;
207 let new = me.with_extension("new");
208 let old = me.with_extension("old");
209 std::fs::write(&new, bytes)?;
210 #[cfg(unix)]
211 {
212 use std::os::unix::fs::PermissionsExt;
213 std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755))?;
214 }
215 let _ = std::fs::remove_file(&old);
216 std::fs::rename(&me, &old).context("could not move the old version aside")?;
217 if let Err(error) = std::fs::rename(&new, &me) {
218 let _ = std::fs::rename(&old, &me);
219 return Err(error).context("could not put the new version in place");
220 }
221 Ok(())
222}
223
224/// Whether this build can update itself.
225pub fn can_update() -> bool {
226 RELEASE_KEY.is_some()
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use ed25519_dalek::Signer;
233
234 #[test]
235 fn versions_compare_as_numbers() {
236 assert!(newer("0.10.0", "0.9.9"));
237 assert!(newer("v1.0.0", "0.9.0"));
238 assert!(!newer("0.2.0", "0.2.0"));
239 assert!(!newer("0.1.9", "0.2.0"));
240 }
241
242 #[test]
243 fn only_the_release_keys_signature_is_trusted() {
244 let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
245 let key = STANDARD.encode(signing.verifying_key().to_bytes());
246 let manifest = br#"{"version":"0.2.0","files":{}}"#;
247 let signature = STANDARD.encode(signing.sign(manifest).to_bytes());
248 assert!(verify(manifest, &signature, &key).is_ok());
249 assert!(verify(br#"{"version":"9.9.9","files":{}}"#, &signature, &key).is_err());
250 let other = STANDARD.encode(ed25519_dalek::SigningKey::from_bytes(&[8u8; 32]).verifying_key().to_bytes());
251 assert!(verify(manifest, &signature, &other).is_err());
252 assert!(verify(manifest, "not base64", &key).is_err());
253 }
254
255 /// Made by scripts/runner-release.mjs: what it signs, the runner checks.
256 #[test]
257 fn the_release_scripts_signatures_check_out() {
258 let key = "4wuwkRbieiO9sWq1UBAcGDjAjin4cwJRG2F8LJVyr6U=";
259 let signature = "aXjDOfxYhm+iHacZwsxMadNxoHX07p62evYNqsXX4UZoDQ7pwWtFnF4jKfiqvAk+AmGVkcE+/uioMR2v+FixCw==";
260 assert!(verify(br#"{"version":"0.2.0","files":{}}"#, signature, key).is_ok());
261 assert!(verify(br#"{"version":"0.2.1","files":{}}"#, signature, key).is_err());
262 }
263
264 #[test]
265 fn platforms_are_named_as_releases_name_them() {
266 let name = platform();
267 assert!(["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "windows-x64", "windows-arm64"].contains(&name.as_str()), "{name}");
268 assert_eq!(sha256(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
269 }
270}

This file's history is long; its oldest lines are credited to the oldest commit read.