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.
| Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it. | 1 | //! `g1t-runner` itself, kept in the app's data folder and run as a child |
| 2 | //! of the app: for the Docker and On this computer backends, and for the | |
| 3 | //! sandbox launcher, which is the same program. | |
| 4 | //! | |
| 5 | //! The app installs and updates it from the server's releases: the | |
| 6 | //! newest one's manifest is `<server>/downloads/runner/latest.json`, which | |
| 7 | //! names each platform's file and its SHA-256, and a download whose | |
| 8 | //! SHA-256 is not the manifest's is thrown away. The runner's own | |
| 9 | //! updating is turned off (`G1T_RUNNER_NO_AUTO_UPDATE`), so only the app | |
| 10 | //! replaces it, and only while it runs nothing. | |
| 11 | ||
| 12 | use std::collections::BTreeMap; | |
| 13 | use std::path::{Path, PathBuf}; | |
| 14 | use std::process::{Child, Stdio}; | |
| 15 | use std::time::Duration; | |
| 16 | ||
| 17 | use serde::Deserialize; | |
| 18 | use sha2::{Digest, Sha256}; | |
| 19 | use url::Url; | |
| 20 | ||
| 21 | use super::backend::quiet; | |
| 22 | ||
| 23 | #[derive(Deserialize)] | |
| 24 | pub struct File { | |
| 25 | pub name: String, | |
| 26 | pub sha256: String, | |
| 27 | } | |
| 28 | ||
| 29 | #[derive(Deserialize)] | |
| 30 | pub struct Manifest { | |
| 31 | pub version: String, | |
| 32 | pub files: BTreeMap<String, File>, | |
| 33 | } | |
| 34 | ||
| 35 | /// This computer's platform, as releases name it. | |
| 36 | pub fn platform() -> String { | |
| 37 | let os = match std::env::consts::OS { | |
| 38 | "macos" => "macos", | |
| 39 | "windows" => "windows", | |
| 40 | _ => "linux", | |
| 41 | }; | |
| 42 | let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" }; | |
| 43 | format!("{os}-{arch}") | |
| 44 | } | |
| 45 | ||
| 46 | /// Where the program is kept. | |
| 47 | pub fn binary(folder: &Path) -> PathBuf { | |
| 48 | folder.join("bin").join(if cfg!(windows) { "g1t-runner.exe" } else { "g1t-runner" }) | |
| 49 | } | |
| 50 | ||
| 51 | /// The version installed, as the manifest named it. | |
| 52 | pub fn installed(folder: &Path) -> Option<String> { | |
| 53 | binary(folder).exists().then(|| std::fs::read_to_string(folder.join("bin").join("version")).ok()).flatten().map(|v| v.trim().to_owned()) | |
| 54 | } | |
| 55 | ||
| 56 | /// `a.b.c` newer than `x.y.z`. | |
| 57 | pub fn newer(candidate: &str, current: &str) -> bool { | |
| 58 | let parts = |v: &str| -> Vec<u64> { v.trim_start_matches('v').split(['.', '-']).take(3).map(|p| p.parse().unwrap_or(0)).collect() }; | |
| 59 | parts(candidate) > parts(current) | |
| 60 | } | |
| 61 | ||
| 62 | pub fn sha256(bytes: &[u8]) -> String { | |
| 63 | Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect() | |
| 64 | } | |
| 65 | ||
| 66 | fn downloads(server: &Url) -> String { | |
| 67 | format!("{}downloads/runner", server.as_str()) | |
| 68 | } | |
| 69 | ||
| 70 | fn get(url: &str) -> Result<Vec<u8>, String> { | |
| 71 | tauri::async_runtime::block_on(async { | |
| 72 | let client = reqwest::Client::builder().timeout(Duration::from_secs(300)).build().map_err(|e| e.to_string())?; | |
| 73 | let response = client.get(url).send().await.map_err(|e| e.to_string())?; | |
| 74 | if !response.status().is_success() { | |
| 75 | return Err(format!("{url} answered {}", response.status())); | |
| 76 | } | |
| 77 | response.bytes().await.map(|bytes| bytes.to_vec()).map_err(|e| e.to_string()) | |
| 78 | }) | |
| 79 | } | |
| 80 | ||
| 81 | /// The newest release's manifest. | |
| 82 | pub fn latest(server: &Url) -> Result<Manifest, String> { | |
| 83 | let bytes = get(&format!("{}/latest.json", downloads(server)))?; | |
| 84 | serde_json::from_slice(&bytes).map_err(|e| format!("the runner's release manifest does not read: {e}")) | |
| 85 | } | |
| 86 | ||
| 87 | /// Downloads a release's program for this platform and checks it against | |
| 88 | /// the manifest's SHA-256. | |
| 89 | pub fn download(server: &Url, manifest: &Manifest) -> Result<Vec<u8>, String> { | |
| 90 | let file = manifest.files.get(&platform()).ok_or_else(|| format!("g1t-runner {} has no build for {}", manifest.version, platform()))?; | |
| 91 | let bytes = get(&format!("{}/{}/{}", downloads(server), manifest.version, file.name))?; | |
| 92 | if sha256(&bytes) != file.sha256.to_ascii_lowercase() { | |
| 93 | return Err("the download's SHA-256 isn't the release's, so it was thrown away".into()); | |
| 94 | } | |
| 95 | Ok(bytes) | |
| 96 | } | |
| 97 | ||
| 98 | /// Puts a checked program in place. The runner must not be running: | |
| 99 | /// Windows will not replace a program that is. | |
| 100 | pub fn put(folder: &Path, bytes: &[u8], version: &str) -> Result<(), String> { | |
| 101 | let path = binary(folder); | |
| 102 | let dir = path.parent().unwrap_or(folder); | |
| 103 | std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; | |
| 104 | let new = path.with_extension("new"); | |
| 105 | std::fs::write(&new, bytes).map_err(|e| e.to_string())?; | |
| 106 | #[cfg(unix)] | |
| 107 | { | |
| 108 | use std::os::unix::fs::PermissionsExt; | |
| 109 | std::fs::set_permissions(&new, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?; | |
| 110 | } | |
| 111 | std::fs::rename(&new, &path).map_err(|e| format!("could not put g1t-runner in place: {e}"))?; | |
| 112 | std::fs::write(dir.join("version"), version).map_err(|e| e.to_string()) | |
| 113 | } | |
| 114 | ||
| 115 | /// Starts `register-and-run`, which registers the first time (from | |
| 116 | /// `G1T_RUNNER_*` in the environment the caller sets) and then runs. | |
| 117 | pub fn spawn(binary: &Path, dir: &Path, envs: &[(&str, String)]) -> std::io::Result<Child> { | |
| 118 | let mut command = quiet(binary); | |
| 119 | command.arg("register-and-run").arg("--dir").arg(dir); | |
| 120 | // What the app's own environment may have set for another runner is not this one's. | |
| 121 | for (name, _) in std::env::vars_os() { | |
| 122 | if name.to_string_lossy().starts_with("G1T_RUNNER_") { | |
| 123 | command.env_remove(&name); | |
| 124 | } | |
| 125 | } | |
| 126 | command.envs(envs.iter().map(|(name, value)| (*name, value.as_str()))); | |
| 127 | command.stdout(Stdio::piped()).stderr(Stdio::piped()); | |
| 128 | // A group of its own, so stopping it reaches what it started. | |
| 129 | #[cfg(unix)] | |
| 130 | std::os::unix::process::CommandExt::process_group(&mut command, 0); | |
| 131 | command.spawn() | |
| 132 | } | |
| 133 | ||
| 134 | /// Unregisters the runner whose configuration is in `dir`, and forgets it. | |
| 135 | pub fn forget(binary: &Path, dir: &Path) { | |
| 136 | if binary.exists() && dir.join("config.json").exists() { | |
| 137 | let _ = quiet(binary).arg("remove").arg("--dir").arg(dir).stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 138 | } | |
| 139 | let _ = std::fs::remove_file(dir.join("config.json")); | |
| 140 | } | |
| 141 | ||
| 142 | /// Stops the runner and what it started: the job it ran in host mode | |
| 143 | /// included. A job cut short this way fails on g1t. | |
| 144 | pub fn stop(child: &mut Child) { | |
| 145 | let pid = child.id().to_string(); | |
| 146 | if cfg!(windows) { | |
| 147 | let _ = quiet("taskkill").args(["/T", "/F", "/PID", &pid]).stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 148 | } else { | |
| 149 | let _ = quiet("kill").args(["-TERM", &format!("-{pid}")]).stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 150 | for _ in 0..40 { | |
| 151 | if matches!(child.try_wait(), Ok(Some(_))) { | |
| 152 | return; | |
| 153 | } | |
| 154 | std::thread::sleep(Duration::from_millis(250)); | |
| 155 | } | |
| 156 | } | |
| 157 | let _ = child.kill(); | |
| 158 | let _ = child.wait(); | |
| 159 | } | |
| 160 | ||
| 161 | /// Removes the containers, networks and volumes of the jobs of the runner | |
| 162 | /// registered in `dir`, as `g1t-runner` does itself when it starts: each | |
| 163 | /// carries the label `sh.g1t.runner=<its id>`. | |
| 164 | pub fn sweep(dir: &Path) { | |
| 165 | #[derive(Deserialize)] | |
| 166 | struct Registered { | |
| 167 | runner: String, | |
| 168 | } | |
| 169 | let Some(registered) = std::fs::read(dir.join("config.json")).ok().and_then(|bytes| serde_json::from_slice::<Registered>(&bytes).ok()) else { return }; | |
| 170 | let label = format!("label=sh.g1t.runner={}", registered.runner); | |
| 171 | for (list, remove) in [ | |
| 172 | (vec!["ps", "-aq", "--filter", &label], vec!["rm", "-f", "-v"]), | |
| 173 | (vec!["network", "ls", "-q", "--filter", &label], vec!["network", "rm"]), | |
| 174 | (vec!["volume", "ls", "-q", "--filter", &label], vec!["volume", "rm", "-f"]), | |
| 175 | ] { | |
| 176 | let Ok(out) = quiet("docker").args(&list).stderr(Stdio::null()).output() else { return }; | |
| 177 | let ids: Vec<String> = String::from_utf8_lossy(&out.stdout).split_whitespace().map(str::to_owned).collect(); | |
| 178 | if !ids.is_empty() { | |
| 179 | let _ = quiet("docker").args(&remove).args(&ids).stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 180 | } | |
| 181 | } | |
| 182 | } | |
| 183 | ||
| 184 | /// A runner the app started before and did not stop (it was ended | |
| 185 | /// without quitting): stopped, if that process is still `g1t-runner`. | |
| 186 | pub fn reap(pid: u32) { | |
| 187 | let pid = pid.to_string(); | |
| 188 | if cfg!(windows) { | |
| 189 | let _ = quiet("taskkill") | |
| 190 | .args(["/T", "/F", "/FI", "IMAGENAME eq g1t-runner.exe", "/PID", &pid]) | |
| 191 | .stdout(Stdio::null()) | |
| 192 | .stderr(Stdio::null()) | |
| 193 | .status(); | |
| 194 | } else { | |
| 195 | let named = quiet("ps").args(["-p", &pid, "-o", "comm="]).output().is_ok_and(|out| String::from_utf8_lossy(&out.stdout).trim().ends_with("g1t-runner")); | |
| 196 | if named { | |
| 197 | let _ = quiet("kill").args(["-TERM", &pid]).status(); | |
| 198 | } | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | #[cfg(test)] | |
| 203 | mod tests { | |
| 204 | use super::*; | |
| 205 | ||
| 206 | #[test] | |
| 207 | fn releases_are_named_and_compared_as_the_runner_does() { | |
| 208 | assert!(["linux-x64", "linux-arm64", "macos-arm64", "macos-x64", "windows-x64", "windows-arm64"].contains(&platform().as_str())); | |
| 209 | assert!(newer("0.10.0", "0.9.9")); | |
| 210 | assert!(!newer("0.2.0", "0.2.0")); | |
| 211 | assert_eq!(sha256(b"abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); | |
| 212 | let manifest: Manifest = serde_json::from_str(r#"{"version":"0.2.3","agent_image":null,"files":{"windows-x64":{"name":"g1t-runner-windows-x64.exe","sha256":"ab"}}}"#).unwrap(); | |
| 213 | assert_eq!(manifest.files["windows-x64"].name, "g1t-runner-windows-x64.exe"); | |
| 214 | assert_eq!(downloads(&Url::parse("https://g1t.sh/").unwrap()), "https://g1t.sh/downloads/runner"); | |
| 215 | } | |
| 216 | ||
| 217 | #[test] | |
| 218 | fn nothing_is_installed_in_an_empty_folder() { | |
| 219 | let dir = std::env::temp_dir().join(format!("g1t-desktop-runner-{}", std::process::id())); | |
| 220 | assert_eq!(installed(&dir), None); | |
| 221 | assert!(binary(&dir).ends_with(if cfg!(windows) { "bin/g1t-runner.exe" } else { "bin/g1t-runner" })); | |
| 222 | } | |
| 223 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.