g1t/crates/runner/src/selfhosted/update.rs

233 lines9,380 bytesCodeBlame

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 incidents1//! 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>,
45}
46
47/// This machine's platform, as releases name it.
48pub fn platform() -> String {
49 let os = match std::env::consts::OS {
50 "macos" => "macos",
51 "windows" => "windows",
52 _ => "linux",
53 };
54 let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" };
55 format!("{os}-{arch}")
56}
57
58fn downloads(config: &Config) -> String {
59 format!("{}/downloads/runner", config.url.trim_end_matches('/'))
60}
61
62/// `a.b.c` newer than `x.y.z`.
63pub fn newer(candidate: &str, current: &str) -> bool {
64 let parts = |v: &str| -> Vec<u64> { v.trim_start_matches('v').split(['.', '-']).take(3).map(|p| p.parse().unwrap_or(0)).collect() };
65 parts(candidate) > parts(current)
66}
67
68pub fn sha256(bytes: &[u8]) -> String {
69 hex::encode(Sha256::digest(bytes))
70}
71
72/// Whether `signature` (base64) is g1t's release key's over `message`.
73pub fn verify(message: &[u8], signature: &str, key: &str) -> Result<()> {
74 let key: [u8; 32] = STANDARD
75 .decode(key.trim())
76 .ok()
77 .and_then(|bytes| bytes.try_into().ok())
78 .ok_or_else(|| anyhow!("the release key built into this runner does not read"))?;
79 let signature: [u8; 64] = STANDARD
80 .decode(signature.trim())
81 .ok()
82 .and_then(|bytes| bytes.try_into().ok())
83 .ok_or_else(|| anyhow!("the release's signature does not read"))?;
84 let key = ed25519_dalek::VerifyingKey::from_bytes(&key).map_err(|_| anyhow!("the release key is not a key"))?;
85 key.verify_strict(message, &ed25519_dalek::Signature::from_bytes(&signature))
86 .map_err(|_| anyhow!("the release's signature is not g1t's"))
87}
88
89/// The newest release, signed. `None` when this build has no release key.
90fn latest_signed(config: &Config) -> Result<Option<Manifest>> {
91 let Some(key) = RELEASE_KEY else { return Ok(None) };
92 let base = downloads(config);
93 let manifest = download(&format!("{base}/latest.json"))?;
94 let signature = String::from_utf8(download(&format!("{base}/latest.json.sig"))?)?;
95 verify(&manifest, &signature, key)?;
96 Ok(Some(serde_json::from_slice(&manifest)?))
97}
98
99/// The manifest of a version: signed when this build can check, otherwise
100/// as fetched over HTTPS.
101fn manifest_of(config: &Config, version: &str) -> Result<Manifest> {
102 if let Some(latest) = latest_signed(config)?.filter(|m| m.version == version) {
103 return Ok(latest);
104 }
105 let bytes = download(&format!("{}/{version}/manifest.json", downloads(config)))?;
106 Ok(serde_json::from_slice(&bytes)?)
107}
108
109/// The image agent work runs in, from the release.
110pub fn agent_image(config: &Config) -> Option<String> {
111 manifest_of(config, VERSION).ok().and_then(|m| m.agent_image)
112}
113
114/// A Linux build of the harness to mount into containers: `--harness`;
115/// this program, on Linux; else the release's for this version, fetched
116/// once and checked.
117pub fn linux_harness(config: &Config, folder: &Path) -> Result<PathBuf> {
118 if let Some(path) = &config.harness {
119 return Ok(path.clone());
120 }
121 if cfg!(target_os = "linux") {
122 return std::env::current_exe().context("could not find this program");
123 }
124 let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" };
125 let path = folder.join("harness").join(VERSION).join("g1t-runner");
126 if path.exists() {
127 return Ok(path);
128 }
129 let manifest = manifest_of(config, VERSION)?;
130 let file = manifest
131 .files
132 .get(&format!("linux-{arch}"))
133 .ok_or_else(|| anyhow!("release {VERSION} has no Linux build for {arch}"))?;
134 log(&format!("Fetching the Linux harness for containers ({})", file.name));
135 let bytes = download(&format!("{}/{VERSION}/{}", downloads(config), file.name))?;
136 if sha256(&bytes) != file.sha256 {
137 bail!("the Linux harness's SHA-256 is not the release's; not using it");
138 }
139 std::fs::create_dir_all(path.parent().unwrap_or(folder))?;
140 std::fs::write(&path, bytes)?;
141 Ok(path)
142}
143
144/// Updates this program to the newest release, if there is one. Returns
145/// the new version when it did.
146pub fn update(config: &Config) -> Result<Option<String>> {
147 let Some(latest) = latest_signed(config)? else {
148 bail!("this build of g1t-runner has no release key, so it cannot check releases; download a release from {}", downloads(config));
149 };
150 if !newer(&latest.version, VERSION) {
151 return Ok(None);
152 }
153 let file = latest
154 .files
155 .get(&platform())
156 .ok_or_else(|| anyhow!("release {} has no build for {}", latest.version, platform()))?;
157 let bytes = download(&format!("{}/{}/{}", downloads(config), latest.version, file.name))?;
158 if sha256(&bytes) != file.sha256 {
159 bail!("the download's SHA-256 is not the release's; not updating");
160 }
161 replace_self(&bytes)?;
162 Ok(Some(latest.version))
163}
164
165/// Puts `bytes` where this program is. The running file is moved aside
166/// first: Windows will not overwrite a running program, but lets it be
167/// renamed.
168fn replace_self(bytes: &[u8]) -> Result<()> {
169 let me = std::env::current_exe()?;
170 let new = me.with_extension("new");
171 let old = me.with_extension("old");
172 std::fs::write(&new, bytes)?;
173 #[cfg(unix)]
174 {
175 use std::os::unix::fs::PermissionsExt;
176 std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755))?;
177 }
178 let _ = std::fs::remove_file(&old);
179 std::fs::rename(&me, &old).context("could not move the old version aside")?;
180 if let Err(error) = std::fs::rename(&new, &me) {
181 let _ = std::fs::rename(&old, &me);
182 return Err(error).context("could not put the new version in place");
183 }
184 Ok(())
185}
186
187/// Whether this build can update itself.
188pub fn can_update() -> bool {
189 RELEASE_KEY.is_some()
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use ed25519_dalek::Signer;
196
197 #[test]
198 fn versions_compare_as_numbers() {
199 assert!(newer("0.10.0", "0.9.9"));
200 assert!(newer("v1.0.0", "0.9.0"));
201 assert!(!newer("0.2.0", "0.2.0"));
202 assert!(!newer("0.1.9", "0.2.0"));
203 }
204
205 #[test]
206 fn only_the_release_keys_signature_is_trusted() {
207 let signing = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
208 let key = STANDARD.encode(signing.verifying_key().to_bytes());
209 let manifest = br#"{"version":"0.2.0","files":{}}"#;
210 let signature = STANDARD.encode(signing.sign(manifest).to_bytes());
211 assert!(verify(manifest, &signature, &key).is_ok());
212 assert!(verify(br#"{"version":"9.9.9","files":{}}"#, &signature, &key).is_err());
213 let other = STANDARD.encode(ed25519_dalek::SigningKey::from_bytes(&[8u8; 32]).verifying_key().to_bytes());
214 assert!(verify(manifest, &signature, &other).is_err());
215 assert!(verify(manifest, "not base64", &key).is_err());
216 }
217
218 /// Made by scripts/runner-release.mjs: what it signs, the runner checks.
219 #[test]
220 fn the_release_scripts_signatures_check_out() {
221 let key = "4wuwkRbieiO9sWq1UBAcGDjAjin4cwJRG2F8LJVyr6U=";
222 let signature = "aXjDOfxYhm+iHacZwsxMadNxoHX07p62evYNqsXX4UZoDQ7pwWtFnF4jKfiqvAk+AmGVkcE+/uioMR2v+FixCw==";
223 assert!(verify(br#"{"version":"0.2.0","files":{}}"#, signature, key).is_ok());
224 assert!(verify(br#"{"version":"0.2.1","files":{}}"#, signature, key).is_err());
225 }
226
227 #[test]
228 fn platforms_are_named_as_releases_name_them() {
229 let name = platform();
230 assert!(["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "windows-x64", "windows-arm64"].contains(&name.as_str()), "{name}");
231 assert_eq!(sha256(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
232 }
233}