Skip to main content

rigtop_core/
update.rs

1//! Updating rigtop: this machine's copy to the latest release, and remote
2//! hosts to this machine's version.
3//!
4//! The latest release is looked up with the system's `curl` (Windows 10 and
5//! later ship one), from where `/releases/latest` redirects to, so no API
6//! rate limit applies. An update feeds a script to `sh -s`, locally or over
7//! the host's SSH connection settings. It finds the rigtop to replace and,
8//! when that sits in a directory the user can write to, installs the
9//! release through its `install.sh` (curl or wget, checksums). Copies
10//! installed by `cargo install` or a package manager are left alone, with
11//! the command to update them instead. No new dependency is needed.
12
13use std::io::Write;
14use std::path::Path;
15use std::process::{Command, Stdio};
16use std::sync::mpsc::{self, Receiver};
17use std::thread;
18use std::time::{Duration, Instant};
19
20use crate::remote::RemoteTarget;
21
22/// Where release files are downloaded from.
23const RELEASES_URL: &str = "https://github.com/akivajp/rigtop/releases/download";
24/// Redirects to the page of the latest release (`.../tag/v0.12.0`).
25const LATEST_URL: &str = "https://github.com/akivajp/rigtop/releases/latest";
26
27/// This rigtop's version, the one remote hosts are updated to.
28pub fn target_version() -> &'static str {
29    env!("CARGO_PKG_VERSION")
30}
31
32/// Whether an agent reporting `agent_version` is older than this rigtop,
33/// so that updating it helps (`false` when unknown or not older).
34pub fn agent_is_older(agent_version: &str) -> bool {
35    match (
36        parse_version(agent_version),
37        parse_version(target_version()),
38    ) {
39        (Some(agent), Some(ours)) => agent < ours,
40        _ => false,
41    }
42}
43
44/// Whether `version` (e.g. an agent's) is `minimum` or later; false when
45/// either cannot be read.
46pub fn version_at_least(version: &str, minimum: &str) -> bool {
47    match (parse_version(version), parse_version(minimum)) {
48        (Some(version), Some(minimum)) => version >= minimum,
49        _ => false,
50    }
51}
52
53/// Whether `version` (e.g. of the latest release) is newer than this rigtop.
54pub fn is_newer(version: &str) -> bool {
55    match (parse_version(version), parse_version(target_version())) {
56        (Some(other), Some(ours)) => other > ours,
57        _ => false,
58    }
59}
60
61/// "0.10.1" -> [0, 10, 1]; pre-release suffixes are ignored.
62fn parse_version(version: &str) -> Option<Vec<u64>> {
63    let core = version.split(['-', '+']).next()?;
64    core.split('.').map(|part| part.parse().ok()).collect()
65}
66
67/// The program an agent command starts: its first word when that is a
68/// path (`/opt/rigtop/rigtop --agent`), else `None` (found on `PATH`).
69fn agent_binary(agent_command: &str) -> Option<&str> {
70    agent_command
71        .split_whitespace()
72        .next()
73        .filter(|program| program.contains('/'))
74}
75
76/// Puts `text` in single quotes for `sh`.
77fn shell_quote(text: &str) -> String {
78    format!("'{}'", text.replace('\'', r"'\''"))
79}
80
81/// The script run on the remote host with `sh -s` to update the rigtop
82/// that `agent_command` starts to `version` (e.g. "0.11.0").
83pub fn update_script(version: &str, agent_command: &str) -> String {
84    script(version, agent_binary(agent_command))
85}
86
87/// The script that updates this machine's copy at `exe` (the running
88/// program, `rigtop` or `rigtop-gui`) to `version`.
89pub fn self_update_script(version: &str, exe: &Path) -> String {
90    script(version, Some(&exe.to_string_lossy()))
91}
92
93/// The update script for the program at `binary`, or the `rigtop` on PATH.
94fn script(version: &str, binary: Option<&str>) -> String {
95    let tag = shell_quote(&format!("v{version}"));
96    let url = shell_quote(&format!("{RELEASES_URL}/v{version}/install.sh"));
97    // A leading "~/" is expanded by the remote shell; quoting would stop it.
98    let find = match binary {
99        Some(path) => match path.strip_prefix("~/") {
100            Some(rest) => format!("bin=\"$HOME\"/{}", shell_quote(rest)),
101            None => format!("bin={}", shell_quote(path)),
102        },
103        // Like the agent connection: PATH first, then the install script's
104        // directory, then cargo's.
105        None => "bin=$(command -v rigtop 2>/dev/null || true)\n\
106                 [ -n \"$bin\" ] || [ ! -x \"$HOME/.local/bin/rigtop\" ] || bin=\"$HOME/.local/bin/rigtop\"\n\
107                 [ -n \"$bin\" ] || [ ! -x \"$HOME/.cargo/bin/rigtop\" ] || bin=\"$HOME/.cargo/bin/rigtop\""
108            .to_owned(),
109    };
110    format!(
111        r#"set -eu
112{find}
113if [ -z "$bin" ] || [ ! -e "$bin" ]; then
114    echo "rigtop update: rigtop not found on this host" >&2
115    exit 1
116fi
117case "$bin" in
118    */Cellar/*)
119        echo "rigtop update: $bin was installed with Homebrew; update it with: brew upgrade rigtop" >&2
120        exit 1
121        ;;
122    */.cargo/bin/*)
123        echo "rigtop update: $bin was installed with cargo; update it with: cargo install $(basename "$bin") --version {version} --locked" >&2
124        exit 1
125        ;;
126esac
127dir=$(dirname "$bin")
128if [ ! -w "$dir" ]; then
129    echo "rigtop update: $dir is not writable (a package manager's copy?); update it with the package manager or the .deb of v{version}" >&2
130    exit 1
131fi
132# Keep rigtop-gui only where it was installed.
133gui=--tui-only
134[ ! -x "$dir/rigtop-gui" ] || gui=
135script=$(mktemp)
136trap 'rm -f "$script"' EXIT
137if command -v curl >/dev/null 2>&1; then
138    curl -fsSL --retry 3 {url} -o "$script"
139elif command -v wget >/dev/null 2>&1; then
140    wget -q -O "$script" {url}
141else
142    echo "rigtop update: needs curl or wget" >&2
143    exit 1
144fi
145sh "$script" --version {tag} --bin-dir "$dir" $gui
146"#
147    )
148}
149
150/// Why a host whose OS is `os` (from its snapshot) cannot be updated from
151/// here, if it cannot: the update script needs a POSIX shell.
152pub fn unsupported_reason(os: &str) -> Option<String> {
153    os.starts_with("Windows").then(|| {
154        "on Windows, update rigtop there with `scoop update rigtop`, or with the zip \
155         from the releases page"
156            .to_owned()
157    })
158}
159
160/// Why this machine's copy cannot update itself, if it cannot: on Windows
161/// a running program cannot be replaced in place (see the README).
162pub fn self_update_unsupported() -> Option<String> {
163    cfg!(windows).then(|| {
164        "on Windows, quit rigtop and run `scoop update rigtop`, or replace it with the \
165         zip from the releases page"
166            .to_owned()
167    })
168}
169
170/// What updating this machine's copy to `version` does, shown before asking.
171pub fn describe_self(version: &str) -> String {
172    format!(
173        "Update rigtop from {ours} to {version}? This downloads the v{version} release \
174         from GitHub, checks it and replaces the rigtop you are running (when it is in a \
175         directory you can write to; copies from cargo or a package manager are left \
176         alone). The new version runs after a restart.",
177        ours = target_version()
178    )
179}
180
181/// One sentence on what an update does, shown before asking.
182pub fn describe(host: &str, agent_version: &str) -> String {
183    format!(
184        "Update rigtop on {host} from {agent_version} to {version}? Over SSH, this \
185         downloads the v{version} release from GitHub, checks it and replaces the \
186         rigtop the agent runs (when it is in a directory you can write to; copies \
187         from cargo or a package manager are left alone). The connection then restarts.",
188        version = target_version()
189    )
190}
191
192/// Whether a remote host running `agent_version` on `os` (from its
193/// snapshot) can and should be updated from here.
194pub fn updatable(agent_version: &str, os: &str) -> bool {
195    agent_is_older(agent_version) && unsupported_reason(os).is_none()
196}
197
198/// What updating several hosts does, shown before asking. `hosts` are
199/// (name, agent version) pairs.
200pub fn describe_many(hosts: &[(String, String)]) -> String {
201    let list: Vec<String> = hosts
202        .iter()
203        .map(|(name, version)| format!("{name} ({version})"))
204        .collect();
205    format!(
206        "Update rigtop on {} to {version}? Over SSH, each downloads the v{version} \
207         release from GitHub, checks it and replaces the rigtop its agent runs (when \
208         it is in a directory you can write to). Each connection restarts when done.",
209        list.join(", "),
210        version = target_version()
211    )
212}
213
214/// An update running in the background.
215pub struct UpdateJob {
216    result: Receiver<Result<String, String>>,
217}
218
219impl UpdateJob {
220    /// Starts updating the host `target` connects to, to this version.
221    pub fn start(target: &RemoteTarget) -> Self {
222        let agent_command = target.args.last().cloned().unwrap_or_default();
223        let script = update_script(target_version(), &agent_command);
224        // Same SSH options and destination, but a shell reading the script.
225        let mut args = target.args.clone();
226        if let Some(last) = args.last_mut() {
227            *last = "sh -s".to_owned();
228        }
229        Self::spawn(target.program.clone(), args, script)
230    }
231
232    /// Starts updating this machine's copy at `exe` to `version`. `exe`
233    /// must be taken at startup: once replaced, the running program's
234    /// path reads "... (deleted)" on Linux.
235    pub fn start_self(version: &str, exe: &Path) -> Self {
236        let script = self_update_script(version, exe);
237        Self::spawn("sh".to_owned(), vec!["-s".to_owned()], script)
238    }
239
240    fn spawn(program: String, args: Vec<String>, script: String) -> Self {
241        let (tx, result) = mpsc::channel();
242        thread::Builder::new()
243            .name("rigtop-update".to_owned())
244            .spawn(move || {
245                let result = run_script(&program, &args, &script).map(|line| {
246                    // install.sh ends with "rigtop install: done: rigtop 0.11.0".
247                    line.strip_prefix("rigtop install: done: ")
248                        .map_or(line.clone(), str::to_owned)
249                });
250                let _ = tx.send(result);
251            })
252            .expect("spawn update thread");
253        Self { result }
254    }
255
256    /// The outcome once finished: the last line of the output, or why it
257    /// failed.
258    pub fn poll(&self) -> Option<Result<String, String>> {
259        self.result.try_recv().ok()
260    }
261}
262
263/// How often [`ReleaseChecker`] looks for a new release by default.
264pub const DEFAULT_CHECK_INTERVAL: Duration = Duration::from_secs(3600);
265
266/// Looks for new releases now and then, in the background.
267pub struct ReleaseChecker {
268    interval: Duration,
269    next_check: Instant,
270    running: Option<Receiver<Result<String, String>>>,
271    /// Newest version seen that is newer than this rigtop.
272    newer: Option<String>,
273    fetch: fn() -> Result<String, String>,
274}
275
276impl ReleaseChecker {
277    /// Checks right away, then every `interval`.
278    pub fn new(interval: Duration) -> Self {
279        Self::with_fetch(interval, latest_release)
280    }
281
282    fn with_fetch(interval: Duration, fetch: fn() -> Result<String, String>) -> Self {
283        Self {
284            interval,
285            next_check: Instant::now(),
286            running: None,
287            newer: None,
288            fetch,
289        }
290    }
291
292    /// A newer release than this rigtop, once one has been seen (e.g.
293    /// "0.12.1"). Call regularly: it also starts the next check when due.
294    pub fn poll(&mut self) -> Option<&str> {
295        if let Some(running) = &self.running
296            && let Ok(result) = running.try_recv()
297        {
298            self.running = None;
299            // A failed check (offline, no curl) just waits for the next one.
300            if let Ok(version) = result
301                && is_newer(&version)
302            {
303                self.newer = Some(version);
304            }
305        }
306        if self.running.is_none() && Instant::now() >= self.next_check {
307            self.next_check = Instant::now() + self.interval;
308            let (tx, rx) = mpsc::channel();
309            let fetch = self.fetch;
310            let started = thread::Builder::new()
311                .name("rigtop-release-check".to_owned())
312                .spawn(move || {
313                    let _ = tx.send(fetch());
314                });
315            if started.is_ok() {
316                self.running = Some(rx);
317            }
318        }
319        self.newer.as_deref()
320    }
321}
322
323/// The version of the latest release (e.g. "0.12.0"), from where GitHub's
324/// `/releases/latest` redirects to.
325pub fn latest_release() -> Result<String, String> {
326    let null = if cfg!(windows) { "NUL" } else { "/dev/null" };
327    let mut command = Command::new("curl");
328    command
329        .args([
330            "-fsSL",
331            "--max-time",
332            "30",
333            "-o",
334            null,
335            "-w",
336            "%{url_effective}",
337        ])
338        .arg(LATEST_URL)
339        .stdin(Stdio::null())
340        .stderr(Stdio::null());
341    #[cfg(windows)]
342    {
343        use std::os::windows::process::CommandExt;
344        // CREATE_NO_WINDOW: a GUI must not flash a console for curl.exe.
345        command.creation_flags(0x0800_0000);
346    }
347    let output = command
348        .output()
349        .map_err(|err| format!("cannot run curl: {err}"))?;
350    if !output.status.success() {
351        return Err(format!("curl exited with {}", output.status));
352    }
353    version_from_release_url(&String::from_utf8_lossy(&output.stdout))
354        .ok_or_else(|| "no release found".to_owned())
355}
356
357/// ".../releases/tag/v0.12.0" -> "0.12.0".
358fn version_from_release_url(url: &str) -> Option<String> {
359    let tag = url.trim().rsplit_once("/tag/")?.1;
360    let version = tag.strip_prefix('v')?;
361    parse_version(version).map(|_| version.to_owned())
362}
363
364/// Runs `program args` with `script` on its stdin and returns the last
365/// line it printed, or, when it fails, the last line of its errors.
366fn run_script(program: &str, args: &[String], script: &str) -> Result<String, String> {
367    let mut command = Command::new(program);
368    command
369        .args(args)
370        .stdin(Stdio::piped())
371        .stdout(Stdio::piped())
372        .stderr(Stdio::piped());
373    #[cfg(windows)]
374    {
375        use std::os::windows::process::CommandExt;
376        // CREATE_NO_WINDOW: a GUI must not flash a console for ssh.exe.
377        command.creation_flags(0x0800_0000);
378    }
379    let mut child = command
380        .spawn()
381        .map_err(|err| format!("cannot start {program}: {err}"))?;
382    if let Some(mut stdin) = child.stdin.take() {
383        // A shell that exits early closes the pipe; its status says why.
384        let _ = stdin.write_all(script.as_bytes());
385    }
386    let output = child
387        .wait_with_output()
388        .map_err(|err| format!("{program} failed: {err}"))?;
389    let last_line = |bytes: &[u8]| {
390        String::from_utf8_lossy(bytes)
391            .lines()
392            .rev()
393            .map(str::trim)
394            .find(|line| !line.is_empty())
395            .map(str::to_owned)
396    };
397    if output.status.success() {
398        Ok(last_line(&output.stdout).unwrap_or_else(|| "done".to_owned()))
399    } else {
400        Err(last_line(&output.stderr)
401            .or_else(|| last_line(&output.stdout))
402            .unwrap_or_else(|| format!("{program} exited with {}", output.status)))
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn versions_compare_by_number() {
412        assert_eq!(parse_version("0.10.1"), Some(vec![0, 10, 1]));
413        assert_eq!(parse_version("1.2.0-rc.1"), Some(vec![1, 2, 0]));
414        assert_eq!(parse_version(""), None);
415        assert!(agent_is_older("0.0.9"));
416        assert!(!agent_is_older(target_version()));
417        assert!(!agent_is_older("999.0.0"));
418        assert!(!agent_is_older(""), "unknown versions are not offered");
419        assert!(updatable("0.0.9", "Linux (Ubuntu 26.04)"));
420        assert!(!updatable("0.0.9", "Windows 11 Pro"), "Scoop updates those");
421        assert!(!updatable(target_version(), "Linux"));
422        assert!(describe_many(&[("nas".to_owned(), "0.0.9".to_owned())]).contains("nas (0.0.9)"));
423        assert!(version_at_least("0.21.0", "0.21.0"));
424        assert!(version_at_least("0.21.1", "0.21.0"));
425        assert!(!version_at_least("0.20.0", "0.21.0"));
426        assert!(!version_at_least("", "0.21.0"));
427    }
428
429    #[test]
430    fn newer_releases_are_found() {
431        assert_eq!(
432            version_from_release_url("https://github.com/akivajp/rigtop/releases/tag/v0.12.1\n"),
433            Some("0.12.1".to_owned())
434        );
435        assert_eq!(
436            version_from_release_url("https://github.com/akivajp/rigtop/releases"),
437            None
438        );
439        assert!(is_newer("999.0.0"));
440        assert!(!is_newer(target_version()));
441
442        let mut checker =
443            ReleaseChecker::with_fetch(Duration::from_secs(3600), || Ok("999.0.0".to_owned()));
444        // The first call starts the check; the answer comes a moment later.
445        let mut found = None;
446        for _ in 0..100 {
447            if let Some(version) = checker.poll() {
448                found = Some(version.to_owned());
449                break;
450            }
451            thread::sleep(Duration::from_millis(10));
452        }
453        assert_eq!(found.as_deref(), Some("999.0.0"));
454
455        let mut checker = ReleaseChecker::with_fetch(Duration::from_secs(3600), || {
456            Ok(target_version().to_owned())
457        });
458        checker.poll();
459        thread::sleep(Duration::from_millis(50));
460        assert!(checker.poll().is_none(), "the same version is not news");
461    }
462
463    /// Asks GitHub (network): `cargo test -p rigtop-core -- --ignored latest_release`.
464    #[test]
465    #[ignore]
466    fn latest_release_from_github() {
467        let version = latest_release().unwrap();
468        assert!(parse_version(&version).is_some(), "{version}");
469    }
470
471    #[test]
472    fn self_update_script_targets_the_running_program() {
473        let script = self_update_script("1.2.3", Path::new("/home/me/.local/bin/rigtop-gui"));
474        assert!(script.contains("bin='/home/me/.local/bin/rigtop-gui'"));
475    }
476
477    #[test]
478    fn windows_hosts_get_directions_instead() {
479        assert!(unsupported_reason("Windows 11 Pro").is_some());
480        assert!(unsupported_reason("Linux (Ubuntu 24.04)").is_none());
481    }
482
483    #[test]
484    fn script_uses_the_agent_command_path() {
485        assert!(update_script("1.2.3", "rigtop --agent").contains("command -v rigtop"));
486        let script = update_script("1.2.3", "/opt/my rig/rigtop --agent");
487        // The first word only; spaces in paths need `command -v`.
488        assert!(script.contains("bin='/opt/my'"));
489        let script = update_script("1.2.3", "~/bin/rigtop --agent");
490        assert!(script.contains(r#"bin="$HOME"/'bin/rigtop'"#));
491        assert!(
492            script.contains(
493                "'https://github.com/akivajp/rigtop/releases/download/v1.2.3/install.sh'"
494            )
495        );
496        assert!(script.contains("--version 'v1.2.3'"));
497    }
498
499    /// Runs the script with `sh` in a sandbox: a fake rigtop on PATH and a
500    /// fake curl whose "install.sh" prints the arguments it got.
501    #[cfg(unix)]
502    #[test]
503    fn script_installs_next_to_the_running_rigtop() {
504        use std::fs;
505        use std::os::unix::fs::PermissionsExt;
506
507        let tmp = tempfile::tempdir().unwrap();
508        let bin = tmp.path().join("bin");
509        let tools = tmp.path().join("tools");
510        fs::create_dir_all(&bin).unwrap();
511        fs::create_dir_all(&tools).unwrap();
512        let executable = |path: &std::path::Path, text: &str| {
513            fs::write(path, text).unwrap();
514            fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
515        };
516        executable(&bin.join("rigtop"), "#!/bin/sh\n");
517        // `curl ... URL -o FILE` writes a stand-in install.sh to FILE.
518        executable(
519            &tools.join("curl"),
520            "#!/bin/sh\nwhile [ $# -gt 1 ]; do [ \"$1\" = -o ] && out=$2; shift; done\n\
521             printf 'echo installed \"$@\"\\n' > \"$out\"\n",
522        );
523        let path = format!("{}:{}:/usr/bin:/bin", bin.display(), tools.display());
524        let run = |script: &str| {
525            run_script(
526                "env",
527                &[format!("PATH={path}"), "sh".to_owned(), "-s".to_owned()],
528                script,
529            )
530        };
531
532        let output = run(&update_script("1.2.3", "rigtop --agent")).unwrap();
533        assert_eq!(
534            output,
535            format!(
536                "installed --version v1.2.3 --bin-dir {} --tui-only",
537                bin.display()
538            )
539        );
540
541        // Not on PATH: ~/.local/bin (the install script's place) is found.
542        let home = tmp.path().join("home");
543        let local = home.join(".local/bin");
544        fs::create_dir_all(&local).unwrap();
545        executable(&local.join("rigtop"), "#!/bin/sh\n");
546        let output = run_script(
547            "env",
548            &[
549                format!("HOME={}", home.display()),
550                format!("PATH={}:/usr/bin:/bin", tools.display()),
551                "sh".to_owned(),
552                "-s".to_owned(),
553            ],
554            &update_script("1.2.3", "rigtop --agent"),
555        )
556        .unwrap();
557        assert!(
558            output.contains(&format!("--bin-dir {}", local.display())),
559            "{output}"
560        );
561
562        // A copy the user cannot replace is left alone, with a reason.
563        fs::set_permissions(&bin, fs::Permissions::from_mode(0o555)).unwrap();
564        let result = run(&update_script("1.2.3", "rigtop --agent"));
565        fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap();
566        // Root can write anywhere (e.g. in containers); then it installs.
567        if let Err(error) = result {
568            assert!(error.contains("is not writable"), "{error}");
569        }
570
571        // cargo's copy is cargo's to update.
572        let cargo = tmp.path().join(".cargo/bin");
573        fs::create_dir_all(&cargo).unwrap();
574        executable(&cargo.join("rigtop"), "#!/bin/sh\n");
575        let command = format!("{}/rigtop --agent", cargo.display());
576        let error = run(&update_script("1.2.3", &command)).unwrap_err();
577        assert!(
578            error.contains("cargo install rigtop --version 1.2.3"),
579            "{error}"
580        );
581
582        // Homebrew's copy is Homebrew's to update.
583        let cellar = tmp.path().join("homebrew/Cellar/rigtop/0.15.0/bin");
584        fs::create_dir_all(&cellar).unwrap();
585        executable(&cellar.join("rigtop"), "#!/bin/sh\n");
586        let error = run(&self_update_script("1.2.3", &cellar.join("rigtop"))).unwrap_err();
587        assert!(error.contains("brew upgrade rigtop"), "{error}");
588
589        // Updating this machine's copy works the same, for rigtop-gui too.
590        executable(&bin.join("rigtop-gui"), "#!/bin/sh\n");
591        let output = run(&self_update_script("1.2.3", &bin.join("rigtop-gui"))).unwrap();
592        assert_eq!(
593            output,
594            format!("installed --version v1.2.3 --bin-dir {}", bin.display())
595        );
596
597        let error = run(&update_script("1.2.3", "/nowhere/rigtop --agent")).unwrap_err();
598        assert!(error.contains("not found"), "{error}");
599    }
600}