flow runner update --from FILE: the runner's side of runner updates 85275e1ef3b7
Checks a new build (this machine's target, --version, a tier it can run here), keeps the installed one as flow.prev, swaps the new one in with one rename, and tells the running runner (SIGUSR1, pid in runner.pid) to leave within --notice so its unit starts the new build. The new build proves itself by an answer from the hub; four starts or ten minutes without one put flow.prev back. --dry-run; [runner.update] allow = false refuses; updates.log records each step. Spec 11.5.3. closes #85, refs #84
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-26 00:56 UTC
- Signature
- ◈ sealed by Andy McCall
- Hash
85275e1ef3b757806addfbb309ed663d825bc085764ddd29692d595a20b3668a- Depends on
- 06a4c472b385 2814f4213e7c 3007d8f011fa 4be5e4cd0af6 5cf1e8aefbb6 62829d15ef09 67851a868a3b 89171fca079c a083cc802892 a4b3f8cb9aae c3db750ed96e c90dd8d5fc36 e0918b887850 ea81796bb757 f98a8f7881e0 fd2d4822f7f3
- Depended on by
- 21a3ea4aab88 8aa45fa6ece5 4dd392d16454 494a1b9a8ac2 3554afefb6aa cedd12342afa 732b4de405e6
- In bookmarks
- main
- In tags
- v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
- Edits
- 16 atoms, 1 tree operation, 8 files
Browse the tree at this change
added crates/flow/src/update.rs +376 −0
| 1 | + | //! Runner updates, the runner's side (docs/jetstream.md §8.2, #85): check |
|
| 2 | + | //! a new `flow` build, stage it beside the running one, swap it in keeping |
|
| 3 | + | //! the old one as `flow.prev`, and have the running runner leave with |
|
| 4 | + | //! notice so its unit starts the new build. The new build proves itself by |
|
| 5 | + | //! reaching the hub; three failed starts, or ten minutes without the hub, |
|
| 6 | + | //! put `flow.prev` back. |
|
| 7 | + | ||
| 8 | + | use anyhow::{bail, Context, Result}; |
|
| 9 | + | use serde::{Deserialize, Serialize}; |
|
| 10 | + | use std::path::{Path, PathBuf}; |
|
| 11 | + | use std::time::Duration; |
|
| 12 | + | ||
| 13 | + | /// The staged build, beside the installed one. |
|
| 14 | + | pub const NEW: &str = "flow.new"; |
|
| 15 | + | /// The build an update replaced, kept to go back to. |
|
| 16 | + | pub const PREV: &str = "flow.prev"; |
|
| 17 | + | /// In the runner's home: the update not yet proven. |
|
| 18 | + | const PENDING: &str = "update.json"; |
|
| 19 | + | /// In the runner's home: every update and rollback, a JSON line each. |
|
| 20 | + | const LOG: &str = "updates.log"; |
|
| 21 | + | /// In the runner's home: the running runner's process id. |
|
| 22 | + | const PID: &str = "runner.pid"; |
|
| 23 | + | /// Starts of an unproven build after which the previous one comes back. |
|
| 24 | + | pub const MAX_STARTS: u32 = 3; |
|
| 25 | + | /// How long a new build has to reach the hub. |
|
| 26 | + | pub const PROVE_WITHIN: Duration = Duration::from_secs(10 * 60); |
|
| 27 | + | ||
| 28 | + | /// An update swapped in and not yet proven. |
|
| 29 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
|
| 30 | + | pub struct Pending { |
|
| 31 | + | pub from: String, |
|
| 32 | + | pub to: String, |
|
| 33 | + | /// The installed binary the update replaced. |
|
| 34 | + | pub exe: PathBuf, |
|
| 35 | + | pub at_ms: u64, |
|
| 36 | + | /// How long the running runner had to finish what it held. |
|
| 37 | + | pub notice_secs: u64, |
|
| 38 | + | /// Starts of the new build so far. |
|
| 39 | + | pub starts: u32, |
|
| 40 | + | } |
|
| 41 | + | ||
| 42 | + | /// What [`install`] did, or would do. |
|
| 43 | + | #[derive(Debug, PartialEq, Eq)] |
|
| 44 | + | pub struct Installed { |
|
| 45 | + | pub from: String, |
|
| 46 | + | pub to: String, |
|
| 47 | + | pub exe: PathBuf, |
|
| 48 | + | /// The running runner told to leave, by process id. |
|
| 49 | + | pub told: Option<u32>, |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | /// The version a `flow --version` line names: `flow 0.3.1 (build)` → `0.3.1`. |
|
| 53 | + | pub fn parse_version(line: &str) -> Option<String> { |
|
| 54 | + | let v = line.trim().strip_prefix("flow ")?.split_whitespace().next()?; |
|
| 55 | + | v.split('.').all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())).then(|| v.to_string()) |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | /// Whether version `a` is older than `b`, part by part. |
|
| 59 | + | pub fn older(a: &str, b: &str) -> bool { |
|
| 60 | + | let parts = |v: &str| v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>(); |
|
| 61 | + | parts(a) < parts(b) |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | /// The ELF machine number of this build's architecture. |
|
| 65 | + | fn machine_here() -> Option<u16> { |
|
| 66 | + | Some(match std::env::consts::ARCH { |
|
| 67 | + | "x86_64" => 62, |
|
| 68 | + | "aarch64" => 183, |
|
| 69 | + | "riscv64" => 243, |
|
| 70 | + | _ => return None, |
|
| 71 | + | }) |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | /// Refuse a file that is not a 64-bit little-endian ELF executable for this |
|
| 75 | + | /// machine: a build for another target would not start, and the rollback |
|
| 76 | + | /// would only notice after three tries. |
|
| 77 | + | pub fn check_target(bytes: &[u8]) -> Result<()> { |
|
| 78 | + | if bytes.len() < 20 || &bytes[..4] != b"\x7fELF" { |
|
| 79 | + | bail!("not an ELF executable"); |
|
| 80 | + | } |
|
| 81 | + | if bytes[4] != 2 || bytes[5] != 1 { |
|
| 82 | + | bail!("not a 64-bit little-endian build"); |
|
| 83 | + | } |
|
| 84 | + | let machine = u16::from_le_bytes([bytes[18], bytes[19]]); |
|
| 85 | + | match machine_here() { |
|
| 86 | + | Some(m) if m == machine => Ok(()), |
|
| 87 | + | Some(_) => bail!("a build for another machine (ELF machine {machine}); this runner is {}", std::env::consts::ARCH), |
|
| 88 | + | None => bail!("updates are not supported on {}", std::env::consts::ARCH), |
|
| 89 | + | } |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | /// Run `file args…` with a time limit, returning its standard output. |
|
| 93 | + | fn run(file: &Path, args: &[&str], limit: Duration) -> Result<String> { |
|
| 94 | + | let mut child = std::process::Command::new(file) |
|
| 95 | + | .args(args) |
|
| 96 | + | .stdin(std::process::Stdio::null()) |
|
| 97 | + | .stdout(std::process::Stdio::piped()) |
|
| 98 | + | .stderr(std::process::Stdio::piped()) |
|
| 99 | + | .spawn() |
|
| 100 | + | .with_context(|| format!("starting {}", file.display()))?; |
|
| 101 | + | let start = std::time::Instant::now(); |
|
| 102 | + | loop { |
|
| 103 | + | if child.try_wait()?.is_some() { |
|
| 104 | + | break; |
|
| 105 | + | } |
|
| 106 | + | if start.elapsed() > limit { |
|
| 107 | + | let _ = child.kill(); |
|
| 108 | + | let _ = child.wait(); |
|
| 109 | + | bail!("`{} {}` did not finish within {} s", file.display(), args.join(" "), limit.as_secs()); |
|
| 110 | + | } |
|
| 111 | + | std::thread::sleep(Duration::from_millis(50)); |
|
| 112 | + | } |
|
| 113 | + | let out = child.wait_with_output()?; |
|
| 114 | + | if !out.status.success() { |
|
| 115 | + | bail!("`{} {}` failed ({}): {}", file.display(), args.join(" "), out.status, String::from_utf8_lossy(&out.stderr).trim()); |
|
| 116 | + | } |
|
| 117 | + | Ok(String::from_utf8_lossy(&out.stdout).into_owned()) |
|
| 118 | + | } |
|
| 119 | + | ||
| 120 | + | /// Check a build as the runner would run it: this machine's target, it |
|
| 121 | + | /// starts and names its version, and it finds the tiers this home offers. |
|
| 122 | + | /// Returns its version. |
|
| 123 | + | pub fn check(file: &Path, home: &Path) -> Result<String> { |
|
| 124 | + | let mut head = vec![0u8; 64]; |
|
| 125 | + | let n = std::io::Read::read(&mut std::fs::File::open(file).with_context(|| format!("opening {}", file.display()))?, &mut head)?; |
|
| 126 | + | check_target(&head[..n])?; |
|
| 127 | + | let limit = Duration::from_secs(30); |
|
| 128 | + | let line = run(file, &["--version"], limit)?; |
|
| 129 | + | let version = parse_version(&line).with_context(|| format!("{} does not say it is flow: {:?}", file.display(), line.trim()))?; |
|
| 130 | + | let home_s = home.to_string_lossy(); |
|
| 131 | + | let tiers = run(file, &["--home", &home_s, "tiers"], limit)?; |
|
| 132 | + | if !tiers.lines().any(|l| l.ends_with(": available")) { |
|
| 133 | + | bail!("flow {version} finds no tier it can run here:\n{}", tiers.trim()); |
|
| 134 | + | } |
|
| 135 | + | Ok(version) |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | fn now_ms() -> u64 { |
|
| 139 | + | std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0) |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | fn log(home: &Path, event: &str, from: &str, to: &str, why: &str) { |
|
| 143 | + | use std::io::Write; |
|
| 144 | + | let line = serde_json::json!({"at_ms": now_ms(), "event": event, "from": from, "to": to, "why": why}); |
|
| 145 | + | if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(home.join(LOG)) { |
|
| 146 | + | let _ = writeln!(f, "{line}"); |
|
| 147 | + | } |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | /// The update not yet proven, if any. |
|
| 151 | + | pub fn pending(home: &Path) -> Option<Pending> { |
|
| 152 | + | serde_json::from_slice(&std::fs::read(home.join(PENDING)).ok()?).ok() |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | fn save(home: &Path, p: &Pending) -> Result<()> { |
|
| 156 | + | let tmp = home.join(format!("{PENDING}.tmp")); |
|
| 157 | + | std::fs::write(&tmp, serde_json::to_vec_pretty(p)?)?; |
|
| 158 | + | std::fs::rename(&tmp, home.join(PENDING))?; |
|
| 159 | + | Ok(()) |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | /// Put `staged` in place of `exe`, keeping `exe` as [`PREV`] beside it. |
|
| 163 | + | /// `exe` is never missing: it is copied aside, then replaced in one rename. |
|
| 164 | + | pub fn swap(exe: &Path, staged: &Path) -> Result<()> { |
|
| 165 | + | let prev = exe.with_file_name(PREV); |
|
| 166 | + | let _ = std::fs::remove_file(&prev); |
|
| 167 | + | if std::fs::hard_link(exe, &prev).is_err() { |
|
| 168 | + | std::fs::copy(exe, &prev).with_context(|| format!("keeping {} as {}", exe.display(), prev.display()))?; |
|
| 169 | + | } |
|
| 170 | + | std::fs::rename(staged, exe).with_context(|| format!("putting {} in place of {}", staged.display(), exe.display()))?; |
|
| 171 | + | Ok(()) |
|
| 172 | + | } |
|
| 173 | + | ||
| 174 | + | /// Put [`PREV`] back in place of `exe`. |
|
| 175 | + | pub fn restore(exe: &Path) -> Result<()> { |
|
| 176 | + | let prev = exe.with_file_name(PREV); |
|
| 177 | + | if !prev.is_file() { |
|
| 178 | + | bail!("{} is not there to go back to", prev.display()); |
|
| 179 | + | } |
|
| 180 | + | std::fs::rename(&prev, exe).with_context(|| format!("putting {} back", prev.display()))?; |
|
| 181 | + | Ok(()) |
|
| 182 | + | } |
|
| 183 | + | ||
| 184 | + | /// Check `file`, stage it beside `exe`, and unless `dry_run` swap it in and |
|
| 185 | + | /// tell the running runner (if any) to leave within `notice`, finishing |
|
| 186 | + | /// what fits and handing back the rest, so its unit starts the new build. |
|
| 187 | + | pub fn install(home: &crate::home::Home, exe: &Path, file: &Path, notice: Duration, dry_run: bool, allow_older: bool) -> Result<Installed> { |
|
| 188 | + | if home.config.runner.as_ref().and_then(|r| r.update.as_ref()).is_some_and(|u| !u.allow) { |
|
| 189 | + | bail!("flow.toml says [runner.update] allow = false: this runner takes no updates"); |
|
| 190 | + | } |
|
| 191 | + | if let Some(p) = pending(&home.root) { |
|
| 192 | + | bail!("the update from {} to {} is not proven yet; wait for it (or for its rollback)", p.from, p.to); |
|
| 193 | + | } |
|
| 194 | + | let from = env!("CARGO_PKG_VERSION").to_string(); |
|
| 195 | + | let staged = exe.with_file_name(NEW); |
|
| 196 | + | let _ = std::fs::remove_file(&staged); |
|
| 197 | + | std::fs::copy(file, &staged).with_context(|| format!("staging {} as {}", file.display(), staged.display()))?; |
|
| 198 | + | { |
|
| 199 | + | use std::os::unix::fs::PermissionsExt; |
|
| 200 | + | std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))?; |
|
| 201 | + | } |
|
| 202 | + | let checked = check(&staged, &home.root).and_then(|to| { |
|
| 203 | + | if older(&to, &from) && !allow_older { |
|
| 204 | + | bail!("flow {to} is older than this runner's {from} (--allow-older to go back)"); |
|
| 205 | + | } |
|
| 206 | + | Ok(to) |
|
| 207 | + | }); |
|
| 208 | + | let to = match checked { |
|
| 209 | + | Ok(v) => v, |
|
| 210 | + | Err(e) => { |
|
| 211 | + | let _ = std::fs::remove_file(&staged); |
|
| 212 | + | return Err(e); |
|
| 213 | + | } |
|
| 214 | + | }; |
|
| 215 | + | if dry_run { |
|
| 216 | + | let _ = std::fs::remove_file(&staged); |
|
| 217 | + | return Ok(Installed { from, to, exe: exe.to_path_buf(), told: None }); |
|
| 218 | + | } |
|
| 219 | + | swap(exe, &staged)?; |
|
| 220 | + | let p = |
|
| 221 | + | Pending { from: from.clone(), to: to.clone(), exe: exe.to_path_buf(), at_ms: now_ms(), notice_secs: notice.as_secs(), starts: 0 }; |
|
| 222 | + | save(&home.root, &p)?; |
|
| 223 | + | log(&home.root, "installed", &from, &to, "flow runner update --from"); |
|
| 224 | + | let told = running(&home.root).filter(|pid| unsafe { libc::kill(*pid as libc::pid_t, libc::SIGUSR1) } == 0); |
|
| 225 | + | Ok(Installed { from, to, exe: exe.to_path_buf(), told }) |
|
| 226 | + | } |
|
| 227 | + | ||
| 228 | + | /// The running runner's process id, from its pid file, if that process is |
|
| 229 | + | /// still a `flow runner`: a stale file's pid may be another program's now, |
|
| 230 | + | /// and SIGUSR1 would end it. Builds before #85 write no pid file (and do |
|
| 231 | + | /// not listen for SIGUSR1), so they are never told. |
|
| 232 | + | fn running(home: &Path) -> Option<u32> { |
|
| 233 | + | let pid: u32 = std::fs::read_to_string(home.join(PID)).ok()?.trim().parse().ok()?; |
|
| 234 | + | let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?; |
|
| 235 | + | is_runner(&cmdline).then_some(pid) |
|
| 236 | + | } |
|
| 237 | + | ||
| 238 | + | /// Whether a NUL-separated command line is `flow … runner` with no subcommand. |
|
| 239 | + | fn is_runner(cmdline: &[u8]) -> bool { |
|
| 240 | + | let args: Vec<&[u8]> = cmdline.split(|b| *b == 0).filter(|a| !a.is_empty()).collect(); |
|
| 241 | + | let Some((argv0, rest)) = args.split_first() else { return false }; |
|
| 242 | + | let name = argv0.rsplit(|b| *b == b'/').next().unwrap_or(argv0); |
|
| 243 | + | name.starts_with(b"flow") && rest.last() == Some(&&b"runner"[..]) |
|
| 244 | + | } |
|
| 245 | + | ||
| 246 | + | /// Note this process as the home's runner, for `flow runner update` to tell. |
|
| 247 | + | pub fn note_runner(home: &Path) { |
|
| 248 | + | let _ = std::fs::write(home.join(PID), std::process::id().to_string()); |
|
| 249 | + | } |
|
| 250 | + | ||
| 251 | + | /// The runner stopped: forget its pid. |
|
| 252 | + | pub fn forget_runner(home: &Path) { |
|
| 253 | + | if std::fs::read_to_string(home.join(PID)).is_ok_and(|p| p.trim() == std::process::id().to_string()) { |
|
| 254 | + | let _ = std::fs::remove_file(home.join(PID)); |
|
| 255 | + | } |
|
| 256 | + | } |
|
| 257 | + | ||
| 258 | + | /// What a runner starting up should do about an update. |
|
| 259 | + | #[derive(Debug, PartialEq, Eq)] |
|
| 260 | + | pub enum Start { |
|
| 261 | + | /// No update waits to be proven. |
|
| 262 | + | Settled, |
|
| 263 | + | /// The new build is on trial: prove it within [`PROVE_WITHIN`]. |
|
| 264 | + | Trial(Pending), |
|
| 265 | + | /// It failed too often; the previous build is back and should be run. |
|
| 266 | + | RolledBack(Pending), |
|
| 267 | + | } |
|
| 268 | + | ||
| 269 | + | /// Count this start of an unproven build; after [`MAX_STARTS`], put the |
|
| 270 | + | /// previous build back. |
|
| 271 | + | pub fn on_start(home: &Path) -> Result<Start> { |
|
| 272 | + | let Some(mut p) = pending(home) else { return Ok(Start::Settled) }; |
|
| 273 | + | p.starts += 1; |
|
| 274 | + | if p.starts > MAX_STARTS { |
|
| 275 | + | roll_back(home, &p, &format!("{MAX_STARTS} starts without reaching the hub"))?; |
|
| 276 | + | return Ok(Start::RolledBack(p)); |
|
| 277 | + | } |
|
| 278 | + | save(home, &p)?; |
|
| 279 | + | Ok(Start::Trial(p)) |
|
| 280 | + | } |
|
| 281 | + | ||
| 282 | + | /// The new build reached the hub: it stays. |
|
| 283 | + | pub fn proven(home: &Path) { |
|
| 284 | + | if let Some(p) = pending(home) { |
|
| 285 | + | let _ = std::fs::remove_file(home.join(PENDING)); |
|
| 286 | + | log(home, "proven", &p.from, &p.to, "reached the hub"); |
|
| 287 | + | eprintln!("flow runner: flow {} proven (it reached the hub); flow {} stays as {PREV}", p.to, p.from); |
|
| 288 | + | } |
|
| 289 | + | } |
|
| 290 | + | ||
| 291 | + | /// Put the previous build back and forget the update. |
|
| 292 | + | pub fn roll_back(home: &Path, p: &Pending, why: &str) -> Result<()> { |
|
| 293 | + | restore(&p.exe)?; |
|
| 294 | + | let _ = std::fs::remove_file(home.join(PENDING)); |
|
| 295 | + | log(home, "rolled back", &p.to, &p.from, why); |
|
| 296 | + | eprintln!("flow runner: rolled back from flow {} to {} ({why})", p.to, p.from); |
|
| 297 | + | Ok(()) |
|
| 298 | + | } |
|
| 299 | + | ||
| 300 | + | #[cfg(test)] |
|
| 301 | + | mod tests { |
|
| 302 | + | use super::*; |
|
| 303 | + | ||
| 304 | + | #[test] |
|
| 305 | + | fn versions_are_read_and_compared() { |
|
| 306 | + | assert_eq!(parse_version("flow 0.3.1 (local build)\n").as_deref(), Some("0.3.1")); |
|
| 307 | + | assert_eq!(parse_version("flow 0.10.0"), Some("0.10.0".into())); |
|
| 308 | + | assert_eq!(parse_version("oxy 0.6.0"), None); |
|
| 309 | + | assert_eq!(parse_version("flow banana"), None); |
|
| 310 | + | assert!(older("0.3.1", "0.10.0"), "by number, not by text"); |
|
| 311 | + | assert!(!older("0.3.1", "0.3.1")); |
|
| 312 | + | assert!(older("0.3", "0.3.1")); |
|
| 313 | + | } |
|
| 314 | + | ||
| 315 | + | #[test] |
|
| 316 | + | fn only_a_build_for_this_machine_passes() { |
|
| 317 | + | let me = std::fs::read(std::env::current_exe().unwrap()).unwrap(); |
|
| 318 | + | check_target(&me[..64]).unwrap(); |
|
| 319 | + | assert!(check_target(b"#!/bin/sh\necho flow 9.9.9\n").unwrap_err().to_string().contains("not an ELF")); |
|
| 320 | + | let mut other = me[..64].to_vec(); |
|
| 321 | + | other[18..20].copy_from_slice(&(if std::env::consts::ARCH == "riscv64" { 62u16 } else { 243u16 }).to_le_bytes()); |
|
| 322 | + | assert!(check_target(&other).unwrap_err().to_string().contains("another machine")); |
|
| 323 | + | } |
|
| 324 | + | ||
| 325 | + | #[test] |
|
| 326 | + | fn a_swap_keeps_the_old_build_and_three_failed_starts_bring_it_back() { |
|
| 327 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 328 | + | let (bin, home) = (dir.path().join("bin"), dir.path().join("home")); |
|
| 329 | + | std::fs::create_dir_all(&bin).unwrap(); |
|
| 330 | + | std::fs::create_dir_all(&home).unwrap(); |
|
| 331 | + | let exe = bin.join("flow"); |
|
| 332 | + | std::fs::write(&exe, "old").unwrap(); |
|
| 333 | + | std::fs::write(bin.join(NEW), "new").unwrap(); |
|
| 334 | + | swap(&exe, &bin.join(NEW)).unwrap(); |
|
| 335 | + | assert_eq!(std::fs::read_to_string(&exe).unwrap(), "new"); |
|
| 336 | + | assert_eq!(std::fs::read_to_string(bin.join(PREV)).unwrap(), "old"); |
|
| 337 | + | assert!(!bin.join(NEW).exists()); |
|
| 338 | + | ||
| 339 | + | assert_eq!(on_start(&home).unwrap(), Start::Settled, "no update, nothing to do"); |
|
| 340 | + | let p = Pending { from: "0.3.1".into(), to: "0.3.2".into(), exe: exe.clone(), at_ms: 1, notice_secs: 600, starts: 0 }; |
|
| 341 | + | save(&home, &p).unwrap(); |
|
| 342 | + | for n in 1..=MAX_STARTS { |
|
| 343 | + | assert_eq!(on_start(&home).unwrap(), Start::Trial(Pending { starts: n, ..p.clone() })); |
|
| 344 | + | } |
|
| 345 | + | assert!(matches!(on_start(&home).unwrap(), Start::RolledBack(_))); |
|
| 346 | + | assert_eq!(std::fs::read_to_string(&exe).unwrap(), "old", "the previous build is back"); |
|
| 347 | + | assert!(pending(&home).is_none()); |
|
| 348 | + | let log = std::fs::read_to_string(home.join(LOG)).unwrap(); |
|
| 349 | + | assert!(log.contains("rolled back") && log.contains("0.3.2"), "{log}"); |
|
| 350 | + | } |
|
| 351 | + | ||
| 352 | + | #[test] |
|
| 353 | + | fn only_a_running_runner_is_told() { |
|
| 354 | + | assert!(is_runner(b"/usr/local/bin/flow\0runner\0")); |
|
| 355 | + | assert!(is_runner(b"flow\0--home\0/srv/flow\0runner\0")); |
|
| 356 | + | assert!(!is_runner(b"flow\0runner\0update\0--from\0x\0"), "not the update command itself"); |
|
| 357 | + | assert!(!is_runner(b"/usr/bin/vim\0runner\0")); |
|
| 358 | + | assert!(!is_runner(b"")); |
|
| 359 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 360 | + | std::fs::write(dir.path().join(PID), std::process::id().to_string()).unwrap(); |
|
| 361 | + | assert_eq!(running(dir.path()), None, "this test is not a flow runner"); |
|
| 362 | + | } |
|
| 363 | + | ||
| 364 | + | #[test] |
|
| 365 | + | fn a_proven_build_stays() { |
|
| 366 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 367 | + | let exe = dir.path().join("flow"); |
|
| 368 | + | std::fs::write(&exe, "new").unwrap(); |
|
| 369 | + | let p = Pending { from: "0.3.1".into(), to: "0.3.2".into(), exe: exe.clone(), at_ms: 1, notice_secs: 0, starts: 1 }; |
|
| 370 | + | save(dir.path(), &p).unwrap(); |
|
| 371 | + | proven(dir.path()); |
|
| 372 | + | assert!(pending(dir.path()).is_none()); |
|
| 373 | + | assert_eq!(on_start(dir.path()).unwrap(), Start::Settled); |
|
| 374 | + | assert!(std::fs::read_to_string(dir.path().join(LOG)).unwrap().contains("proven")); |
|
| 375 | + | } |
|
| 376 | + | } |
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **`flow runner update --from FILE` (#85):** the runner's side of self-updating runners (docs/jetstream.md §8.2). It checks a new build (this machine's target, `--version`, a tier it can run here), keeps the installed one as `flow.prev`, swaps the new one in with a single rename, and tells the running runner (SIGUSR1, its pid in `runner.pid`) to leave within `--notice` seconds (600 by default): jobs that fit finish, the rest are handed back with their checkpoints, and the unit starts the new build. The new build proves itself by getting an answer from the hub; four starts or ten minutes without one put `flow.prev` back. `--dry-run` changes nothing; `[runner.update] allow = false` in `flow.toml` refuses every update; `updates.log` in the home records each install, proof and rollback. Spec §11.5.3. |
|
| 7 | 8 | - **A local `flow run` of a WASM or syscall job copies the workspace whole:** it copied into a directory it never made, so a file at the top of the workspace (a README) failed with `No such file or directory`. |
|
| 8 | 9 | - **Checkpoints: a handed-back job resumes (#77):** `checkpoint "/src/…"` on a job names directories that hold its progress; a runner handing the job back packs and keeps them (`POST …/checkpoint`), and the next attempt's runner restores them before running the same exec, so it carries on rather than starting again. The receipt lists the checkpoint among its inputs. WASM and syscall tiers for now. Spec §11.5.2. |
|
| 9 | 10 | - **A leaving runner finishes what fits its notice (#76):** a claim carries `expected_ms` (the median of the job's recent passing runs, from OxygenHub 0.9.4); `runner::leave_within(reason, notice)` finishes and reports a job whose expected rest fits the notice and hands back the others, and any still running when it is up. `runner::leave` is the same with no notice. Spec §11.5.2. |
|
| … | 94 unchanged lines |
modified crates/flow/src/home.rs +16 −0
| … | 86 unchanged lines |
||
| 87 | 87 | /// A bearer token a scrape of the metrics must send. |
|
| 88 | 88 | #[serde(default)] |
|
| 89 | 89 | pub metrics_token: Option<String>, |
|
| 90 | + | /// `[runner.update]`: whether this runner takes updates (#85). |
|
| 91 | + | #[serde(default)] |
|
| 92 | + | pub update: Option<UpdateConfig>, |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | /// `[runner.update]` in flow.toml (docs/jetstream.md §8.2): the runner's |
|
| 96 | + | /// last word on updates, whatever the hub's policy says. |
|
| 97 | + | #[derive(Clone, Debug, Deserialize)] |
|
| 98 | + | pub struct UpdateConfig { |
|
| 99 | + | /// `false` refuses every update, so a site can hold a machine back. |
|
| 100 | + | #[serde(default = "yes")] |
|
| 101 | + | pub allow: bool, |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | fn yes() -> bool { |
|
| 105 | + | true |
|
| 90 | 106 | } |
|
| 91 | 107 | ||
| 92 | 108 | impl RunnerConfig { |
|
| … | 196 unchanged lines |
modified crates/flow/src/lib.rs +1 −0
| … | 18 unchanged lines |
||
| 19 | 19 | pub mod runner; |
|
| 20 | 20 | pub mod swarm; |
|
| 21 | 21 | pub mod syscall; |
|
| 22 | + | pub mod update; |
|
| 22 | 23 | pub mod wait; |
|
| 23 | 24 | pub mod wasm; |
|
| 24 | 25 | pub mod workspace; |
modified crates/flow/src/main.rs +56 −0
| … | 43 unchanged lines |
||
| 44 | 44 | /// Measure this machine as a runner does: its speed score (100 on the |
|
| 45 | 45 | /// reference machine) and class, free disk and load (spec §11.1) |
|
| 46 | 46 | Bench, |
|
| 47 | + | /// Install a new flow build in place of this one (docs/jetstream.md |
|
| 48 | + | /// §8.2): check it, keep this one as flow.prev, and have the running |
|
| 49 | + | /// runner leave with notice so its unit starts the new build. A build |
|
| 50 | + | /// that does not reach the hub goes back to flow.prev by itself |
|
| 51 | + | Update { |
|
| 52 | + | /// The new flow binary |
|
| 53 | + | #[arg(long)] |
|
| 54 | + | from: PathBuf, |
|
| 55 | + | /// Seconds the running runner has to finish what it holds; jobs |
|
| 56 | + | /// that will not finish in time are handed back with their checkpoints |
|
| 57 | + | #[arg(long, default_value_t = 600)] |
|
| 58 | + | notice: u64, |
|
| 59 | + | /// Check the build and say what would happen, changing nothing |
|
| 60 | + | #[arg(long)] |
|
| 61 | + | dry_run: bool, |
|
| 62 | + | /// Install a build older than this one |
|
| 63 | + | #[arg(long)] |
|
| 64 | + | allow_older: bool, |
|
| 65 | + | /// The installed binary to replace (default: this one) |
|
| 66 | + | #[arg(long)] |
|
| 67 | + | exe: Option<PathBuf>, |
|
| 68 | + | }, |
|
| 47 | 69 | } |
|
| 48 | 70 | ||
| 49 | 71 | #[derive(Subcommand)] |
|
| … | 203 unchanged lines |
||
| 253 | 275 | }; |
|
| 254 | 276 | let got = runner::register(&home, &hub, &token, &name, ca, label)?; |
|
| 255 | 277 | println!("registered as {} (runner {}); start it with `flow runner`", got.name, got.id); |
|
| 278 | + | Ok(()) |
|
| 279 | + | } |
|
| 280 | + | Cmd::Runner { cmd: Some(RunnerCmd::Update { from, notice, dry_run, allow_older, exe }) } => { |
|
| 281 | + | let home = Home::locate(cli.home.as_deref())?; |
|
| 282 | + | let exe = match exe { |
|
| 283 | + | Some(e) => e, |
|
| 284 | + | None => std::env::current_exe().context("finding this flow binary")?, |
|
| 285 | + | }; |
|
| 286 | + | let done = flow::update::install(&home, &exe, &from, Duration::from_secs(notice), dry_run, allow_older)?; |
|
| 287 | + | if dry_run { |
|
| 288 | + | println!( |
|
| 289 | + | "flow {} checks out: it would replace flow {} at {} (dry run: nothing changed)", |
|
| 290 | + | done.to, |
|
| 291 | + | done.from, |
|
| 292 | + | done.exe.display() |
|
| 293 | + | ); |
|
| 294 | + | return Ok(()); |
|
| 295 | + | } |
|
| 296 | + | println!("flow {} installed at {}; flow {} kept as {}", done.to, done.exe.display(), done.from, flow::update::PREV); |
|
| 297 | + | match done.told { |
|
| 298 | + | Some(pid) => println!("the runner (pid {pid}) leaves within {notice} s, then its unit starts flow {}", done.to), |
|
| 299 | + | None => println!( |
|
| 300 | + | "no runner of this home was told (none runs, or one from before runner updates, which does not listen): \ |
|
| 301 | + | restart its unit, e.g. `systemctl restart --no-block flow-runner`; it drains and starts flow {}", |
|
| 302 | + | done.to |
|
| 303 | + | ), |
|
| 304 | + | } |
|
| 256 | 305 | Ok(()) |
|
| 257 | 306 | } |
|
| 258 | 307 | Cmd::Runner { cmd: None } => { |
|
| 259 | 308 | let home = Home::locate(cli.home.as_deref())?; |
|
| 309 | + | // A new build on trial that keeps failing to start goes back to |
|
| 310 | + | // the one before, which then runs in its place (#85). |
|
| 311 | + | if let flow::update::Start::RolledBack(p) = flow::update::on_start(&home.root)? { |
|
| 312 | + | use std::os::unix::process::CommandExt; |
|
| 313 | + | let err = std::process::Command::new(&p.exe).args(std::env::args_os().skip(1)).exec(); |
|
| 314 | + | bail!("starting flow {} again after the rollback: {err}", p.from); |
|
| 315 | + | } |
|
| 260 | 316 | // The runner's oxy state (identity, user config) stays in its home. |
|
| 261 | 317 | if std::env::var_os("OXY_HOME").is_none() { |
|
| 262 | 318 | std::env::set_var("OXY_HOME", home.root.join("oxy")); |
|
| … | 294 unchanged lines |
modified crates/flow/src/runner.rs +42 −2
| … | 613 unchanged lines |
||
| 614 | 614 | ||
| 615 | 615 | pub fn serve(home: &Home) -> Result<()> { |
|
| 616 | 616 | drain_on_stop(); |
|
| 617 | - | serve_until_drained(home) |
|
| 617 | + | crate::update::note_runner(&home.root); |
|
| 618 | + | // `flow runner update` swapped a new build in and asks this one to |
|
| 619 | + | // leave with the notice it gave (#85). |
|
| 620 | + | let handler = on_update as extern "C" fn(libc::c_int) as libc::sighandler_t; |
|
| 621 | + | unsafe { libc::signal(libc::SIGUSR1, handler) }; |
|
| 622 | + | if let Some(p) = crate::update::pending(&home.root) { |
|
| 623 | + | // On trial: a build that does not reach the hub in time goes back. |
|
| 624 | + | let root = home.root.clone(); |
|
| 625 | + | std::thread::spawn(move || { |
|
| 626 | + | std::thread::sleep(crate::update::PROVE_WITHIN); |
|
| 627 | + | if crate::update::pending(&root).is_some_and(|now| now.to == p.to && now.at_ms == p.at_ms) { |
|
| 628 | + | let why = format!("did not reach the hub within {} minutes", crate::update::PROVE_WITHIN.as_secs() / 60); |
|
| 629 | + | if crate::update::roll_back(&root, &p, &why).is_ok() { |
|
| 630 | + | std::process::exit(1); |
|
| 631 | + | } |
|
| 632 | + | } |
|
| 633 | + | }); |
|
| 634 | + | } |
|
| 635 | + | let served = serve_until_drained(home); |
|
| 636 | + | crate::update::forget_runner(&home.root); |
|
| 637 | + | served |
|
| 618 | 638 | } |
|
| 619 | 639 | ||
| 640 | + | /// Set by SIGUSR1: a new build is in place; leave for it. |
|
| 641 | + | static UPDATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); |
|
| 642 | + | ||
| 643 | + | extern "C" fn on_update(_: libc::c_int) { |
|
| 644 | + | UPDATE.store(true, std::sync::atomic::Ordering::SeqCst); |
|
| 645 | + | } |
|
| 646 | + | ||
| 620 | 647 | /// The runner as a library, as the Android app runs it (oxygenhub-android#8): |
|
| 621 | 648 | /// no signal handlers; [`stop`] drains it, and this returns once the jobs |
|
| 622 | 649 | /// it holds are done and reported. It may be started again afterwards. |
|
| … | 109 unchanged lines |
||
| 732 | 759 | *heard = Instant::now(); |
|
| 733 | 760 | } |
|
| 734 | 761 | }; |
|
| 762 | + | let mut on_trial = crate::update::pending(&home.root).is_some(); |
|
| 735 | 763 | loop { |
|
| 764 | + | if UPDATE.swap(false, std::sync::atomic::Ordering::SeqCst) { |
|
| 765 | + | let p = crate::update::pending(&home.root); |
|
| 766 | + | let notice = Duration::from_secs(p.as_ref().map_or(0, |p| p.notice_secs)); |
|
| 767 | + | let to = p.map_or_else(|| "a new build".to_string(), |p| format!("flow {}", p.to)); |
|
| 768 | + | eprintln!("flow runner {name}: updating to {to}: leaving within {} s", notice.as_secs()); |
|
| 769 | + | leave_within(&format!("updating to {to}"), notice); |
|
| 770 | + | } |
|
| 736 | 771 | // Draining (oxygenflow#50): told to stop, claim nothing more and |
|
| 737 | 772 | // stop once every job held is done and reported. |
|
| 738 | 773 | if stopping() { |
|
| … | 41 unchanged lines |
||
| 780 | 815 | caches: Some(cache_times.keys().cloned().collect()), |
|
| 781 | 816 | cache_times: Some(cache_times), |
|
| 782 | 817 | }; |
|
| 783 | - | match hub.claim(&req) { |
|
| 818 | + | let answer = hub.claim(&req); |
|
| 819 | + | if on_trial && answer.is_ok() { |
|
| 820 | + | crate::update::proven(&home.root); |
|
| 821 | + | on_trial = false; |
|
| 822 | + | } |
|
| 823 | + | match answer { |
|
| 784 | 824 | Ok(Some(claim)) => { |
|
| 785 | 825 | idle = Duration::from_secs(2); |
|
| 786 | 826 | crate::metrics::CLAIMS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
|
| … | 1269 unchanged lines |
modified docs/jetstream.md +2 −1
| … | 372 unchanged lines |
||
| 373 | 373 | app only through its store or an install), so they follow the app. |
|
| 374 | 374 | ||
| 375 | 375 | **Order of work:** (1) the runner side, with a local source (`flow runner |
|
| 376 | - | update --from FILE`): check, stage, drain, swap, prove, roll back; (2) the |
|
| 376 | + | update --from FILE`): check, stage, drain, swap, prove, roll back (done, |
|
| 377 | + | #85: spec §11.5.3); (2) the |
|
| 377 | 378 | policy on the hub (opt in and out, channels, windows, dry run) and the |
|
| 378 | 379 | update in the claim answer; (3) test groups, waves and health; (4) the |
|
| 379 | 380 | swarm as transport; (5) Admin › Runners › Updates and `oh runner update`. |
|
| … | 169 unchanged lines |
modified docs/spec.md +30 −0
| … | 853 unchanged lines |
||
| 854 | 854 | by finishing what it holds. Checkpoints (#77), so the next attempt |
|
| 855 | 855 | resumes rather than restarts, come next. |
|
| 856 | 856 | ||
| 857 | + | ### 11.5.3 Updating a runner (oxygenflow#85) |
|
| 858 | + | ||
| 859 | + | `flow runner update --from FILE` installs a new `flow` build in place of |
|
| 860 | + | the one it runs as (`--exe` names another), the runner's side of |
|
| 861 | + | self-updating runners (docs/jetstream.md §8.2): |
|
| 862 | + | ||
| 863 | + | 1. **Refused** when the home's `flow.toml` says `[runner.update] allow = |
|
| 864 | + | false`, when an earlier update is not yet proven, or when the build is |
|
| 865 | + | older than the installed one (unless `--allow-older`). |
|
| 866 | + | 2. **Checked:** a 64-bit little-endian ELF for this machine; it names |
|
| 867 | + | itself (`--version`, `flow X.Y.Z …`) and finds a tier it can run here |
|
| 868 | + | (`tiers` against the runner's home), each within 30 seconds. |
|
| 869 | + | 3. **Staged and swapped:** copied beside the installed binary as |
|
| 870 | + | `flow.new` and checked there; the installed one is kept as |
|
| 871 | + | `flow.prev` (a hard link, else a copy), then `flow.new` replaces it in |
|
| 872 | + | one rename, so `flow` is never missing. `--dry-run` stops before the |
|
| 873 | + | swap and changes nothing. |
|
| 874 | + | 4. **The running runner leaves:** a runner writes its pid to |
|
| 875 | + | `runner.pid` in its home, and the update sends it SIGUSR1 (after |
|
| 876 | + | checking that pid is still a `flow … runner`). It then leaves with |
|
| 877 | + | `--notice` (default 600 s, §11.5.2): jobs expected to finish in time |
|
| 878 | + | finish, the rest are handed back with their checkpoints, and it exits |
|
| 879 | + | 0, so its unit's `Restart=always` starts the new build. A runner from |
|
| 880 | + | before #85 is not told; restarting its unit drains it as before. |
|
| 881 | + | 5. **Proven or rolled back:** the update waits in `update.json` until the |
|
| 882 | + | new build's first claim gets an answer from the hub. Each start counts; |
|
| 883 | + | on the fourth start without that answer, or after ten minutes running |
|
| 884 | + | without it, `flow.prev` is put back (and on a start, run in its place). |
|
| 885 | + | `updates.log` in the home records every install, proof and rollback. |
|
| 886 | + | ||
| 857 | 887 | ### 11.6 Conformance |
|
| 858 | 888 | ||
| 859 | 889 | 1. Architecture Safety: a job whose image is `x86_64` MUST never be |
|
| … | 10 unchanged lines |