| 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 | } |