Skip to main content

rigtop_core/
update.rs

1//! Updating rigtop on a remote host to this machine's version.
2//!
3//! A script is fed to `sh -s` over the host's SSH connection settings. It
4//! finds the rigtop that the agent command runs and, when that sits in a
5//! directory the user can write to, replaces it with the release of this
6//! version through the release's `install.sh` (curl or wget, checksums).
7//! Copies installed by `cargo install` or a package manager are left alone,
8//! with the command to update them instead. No new dependency is needed on
9//! either side.
10
11use std::io::Write;
12use std::process::{Command, Stdio};
13use std::sync::mpsc::{self, Receiver};
14use std::thread;
15
16use crate::remote::RemoteTarget;
17
18/// Where release files are downloaded from.
19const RELEASES_URL: &str = "https://github.com/akivajp/rigtop/releases/download";
20
21/// This rigtop's version, the one remote hosts are updated to.
22pub fn target_version() -> &'static str {
23    env!("CARGO_PKG_VERSION")
24}
25
26/// Whether an agent reporting `agent_version` is older than this rigtop,
27/// so that updating it helps (`false` when unknown or not older).
28pub fn agent_is_older(agent_version: &str) -> bool {
29    match (
30        parse_version(agent_version),
31        parse_version(target_version()),
32    ) {
33        (Some(agent), Some(ours)) => agent < ours,
34        _ => false,
35    }
36}
37
38/// "0.10.1" -> [0, 10, 1]; pre-release suffixes are ignored.
39fn parse_version(version: &str) -> Option<Vec<u64>> {
40    let core = version.split(['-', '+']).next()?;
41    core.split('.').map(|part| part.parse().ok()).collect()
42}
43
44/// The program an agent command starts: its first word when that is a
45/// path (`/opt/rigtop/rigtop --agent`), else `None` (found on `PATH`).
46fn agent_binary(agent_command: &str) -> Option<&str> {
47    agent_command
48        .split_whitespace()
49        .next()
50        .filter(|program| program.contains('/'))
51}
52
53/// Puts `text` in single quotes for `sh`.
54fn shell_quote(text: &str) -> String {
55    format!("'{}'", text.replace('\'', r"'\''"))
56}
57
58/// The script run on the remote host with `sh -s` to update the rigtop
59/// that `agent_command` starts to `version` (e.g. "0.11.0").
60pub fn update_script(version: &str, agent_command: &str) -> String {
61    let tag = shell_quote(&format!("v{version}"));
62    let url = shell_quote(&format!("{RELEASES_URL}/v{version}/install.sh"));
63    // A leading "~/" is expanded by the remote shell; quoting would stop it.
64    let find = match agent_binary(agent_command) {
65        Some(path) => match path.strip_prefix("~/") {
66            Some(rest) => format!("bin=\"$HOME\"/{}", shell_quote(rest)),
67            None => format!("bin={}", shell_quote(path)),
68        },
69        // Like the agent connection: PATH first, then cargo's directory.
70        None => "bin=$(command -v rigtop 2>/dev/null || true)\n\
71                 [ -n \"$bin\" ] || [ ! -x \"$HOME/.cargo/bin/rigtop\" ] || bin=\"$HOME/.cargo/bin/rigtop\""
72            .to_owned(),
73    };
74    format!(
75        r#"set -eu
76{find}
77if [ -z "$bin" ] || [ ! -e "$bin" ]; then
78    echo "rigtop update: rigtop not found on this host" >&2
79    exit 1
80fi
81case "$bin" in
82    */.cargo/bin/rigtop)
83        echo "rigtop update: $bin was installed with cargo; update it with: cargo install rigtop --version {version} --locked" >&2
84        exit 1
85        ;;
86esac
87dir=$(dirname "$bin")
88if [ ! -w "$dir" ]; then
89    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
90    exit 1
91fi
92# Keep rigtop-gui only where it was installed.
93gui=--tui-only
94[ ! -x "$dir/rigtop-gui" ] || gui=
95script=$(mktemp)
96trap 'rm -f "$script"' EXIT
97if command -v curl >/dev/null 2>&1; then
98    curl -fsSL --retry 3 {url} -o "$script"
99elif command -v wget >/dev/null 2>&1; then
100    wget -q -O "$script" {url}
101else
102    echo "rigtop update: needs curl or wget" >&2
103    exit 1
104fi
105sh "$script" --version {tag} --bin-dir "$dir" $gui
106"#
107    )
108}
109
110/// Why a host whose OS is `os` (from its snapshot) cannot be updated from
111/// here, if it cannot: the update script needs a POSIX shell.
112pub fn unsupported_reason(os: &str) -> Option<String> {
113    os.starts_with("Windows").then(|| {
114        "on Windows, update rigtop there with `scoop update rigtop`, or with the zip \
115         from the releases page"
116            .to_owned()
117    })
118}
119
120/// One sentence on what an update does, shown before asking.
121pub fn describe(host: &str, agent_version: &str) -> String {
122    format!(
123        "Update rigtop on {host} from {agent_version} to {version}? Over SSH, this \
124         downloads the v{version} release from GitHub, checks it and replaces the \
125         rigtop the agent runs (when it is in a directory you can write to; copies \
126         from cargo or a package manager are left alone). The connection then restarts.",
127        version = target_version()
128    )
129}
130
131/// An update running in the background.
132pub struct UpdateJob {
133    result: Receiver<Result<String, String>>,
134}
135
136impl UpdateJob {
137    /// Starts updating the host `target` connects to, to this version.
138    pub fn start(target: &RemoteTarget) -> Self {
139        let agent_command = target.args.last().cloned().unwrap_or_default();
140        let script = update_script(target_version(), &agent_command);
141        // Same SSH options and destination, but a shell reading the script.
142        let mut args = target.args.clone();
143        if let Some(last) = args.last_mut() {
144            *last = "sh -s".to_owned();
145        }
146        let program = target.program.clone();
147        let (tx, result) = mpsc::channel();
148        thread::Builder::new()
149            .name("rigtop-update".to_owned())
150            .spawn(move || {
151                let result = run_script(&program, &args, &script).map(|line| {
152                    // install.sh ends with "rigtop install: done: rigtop 0.11.0".
153                    line.strip_prefix("rigtop install: done: ")
154                        .map_or(line.clone(), str::to_owned)
155                });
156                let _ = tx.send(result);
157            })
158            .expect("spawn update thread");
159        Self { result }
160    }
161
162    /// The outcome once finished: the last line of the output, or why it
163    /// failed.
164    pub fn poll(&self) -> Option<Result<String, String>> {
165        self.result.try_recv().ok()
166    }
167}
168
169/// Runs `program args` with `script` on its stdin and returns the last
170/// line it printed, or, when it fails, the last line of its errors.
171fn run_script(program: &str, args: &[String], script: &str) -> Result<String, String> {
172    let mut command = Command::new(program);
173    command
174        .args(args)
175        .stdin(Stdio::piped())
176        .stdout(Stdio::piped())
177        .stderr(Stdio::piped());
178    #[cfg(windows)]
179    {
180        use std::os::windows::process::CommandExt;
181        // CREATE_NO_WINDOW: a GUI must not flash a console for ssh.exe.
182        command.creation_flags(0x0800_0000);
183    }
184    let mut child = command
185        .spawn()
186        .map_err(|err| format!("cannot start {program}: {err}"))?;
187    if let Some(mut stdin) = child.stdin.take() {
188        // A shell that exits early closes the pipe; its status says why.
189        let _ = stdin.write_all(script.as_bytes());
190    }
191    let output = child
192        .wait_with_output()
193        .map_err(|err| format!("{program} failed: {err}"))?;
194    let last_line = |bytes: &[u8]| {
195        String::from_utf8_lossy(bytes)
196            .lines()
197            .rev()
198            .map(str::trim)
199            .find(|line| !line.is_empty())
200            .map(str::to_owned)
201    };
202    if output.status.success() {
203        Ok(last_line(&output.stdout).unwrap_or_else(|| "done".to_owned()))
204    } else {
205        Err(last_line(&output.stderr)
206            .or_else(|| last_line(&output.stdout))
207            .unwrap_or_else(|| format!("{program} exited with {}", output.status)))
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn versions_compare_by_number() {
217        assert_eq!(parse_version("0.10.1"), Some(vec![0, 10, 1]));
218        assert_eq!(parse_version("1.2.0-rc.1"), Some(vec![1, 2, 0]));
219        assert_eq!(parse_version(""), None);
220        assert!(agent_is_older("0.0.9"));
221        assert!(!agent_is_older(target_version()));
222        assert!(!agent_is_older("999.0.0"));
223        assert!(!agent_is_older(""), "unknown versions are not offered");
224    }
225
226    #[test]
227    fn windows_hosts_get_directions_instead() {
228        assert!(unsupported_reason("Windows 11 Pro").is_some());
229        assert!(unsupported_reason("Linux (Ubuntu 24.04)").is_none());
230    }
231
232    #[test]
233    fn script_uses_the_agent_command_path() {
234        assert!(update_script("1.2.3", "rigtop --agent").contains("command -v rigtop"));
235        let script = update_script("1.2.3", "/opt/my rig/rigtop --agent");
236        // The first word only; spaces in paths need `command -v`.
237        assert!(script.contains("bin='/opt/my'"));
238        let script = update_script("1.2.3", "~/bin/rigtop --agent");
239        assert!(script.contains(r#"bin="$HOME"/'bin/rigtop'"#));
240        assert!(
241            script.contains(
242                "'https://github.com/akivajp/rigtop/releases/download/v1.2.3/install.sh'"
243            )
244        );
245        assert!(script.contains("--version 'v1.2.3'"));
246    }
247
248    /// Runs the script with `sh` in a sandbox: a fake rigtop on PATH and a
249    /// fake curl whose "install.sh" prints the arguments it got.
250    #[cfg(unix)]
251    #[test]
252    fn script_installs_next_to_the_running_rigtop() {
253        use std::fs;
254        use std::os::unix::fs::PermissionsExt;
255
256        let tmp = tempfile::tempdir().unwrap();
257        let bin = tmp.path().join("bin");
258        let tools = tmp.path().join("tools");
259        fs::create_dir_all(&bin).unwrap();
260        fs::create_dir_all(&tools).unwrap();
261        let executable = |path: &std::path::Path, text: &str| {
262            fs::write(path, text).unwrap();
263            fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
264        };
265        executable(&bin.join("rigtop"), "#!/bin/sh\n");
266        // `curl ... URL -o FILE` writes a stand-in install.sh to FILE.
267        executable(
268            &tools.join("curl"),
269            "#!/bin/sh\nwhile [ $# -gt 1 ]; do [ \"$1\" = -o ] && out=$2; shift; done\n\
270             printf 'echo installed \"$@\"\\n' > \"$out\"\n",
271        );
272        let path = format!("{}:{}:/usr/bin:/bin", bin.display(), tools.display());
273        let run = |script: &str| {
274            run_script(
275                "env",
276                &[format!("PATH={path}"), "sh".to_owned(), "-s".to_owned()],
277                script,
278            )
279        };
280
281        let output = run(&update_script("1.2.3", "rigtop --agent")).unwrap();
282        assert_eq!(
283            output,
284            format!(
285                "installed --version v1.2.3 --bin-dir {} --tui-only",
286                bin.display()
287            )
288        );
289
290        // A copy the user cannot replace is left alone, with a reason.
291        fs::set_permissions(&bin, fs::Permissions::from_mode(0o555)).unwrap();
292        let result = run(&update_script("1.2.3", "rigtop --agent"));
293        fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap();
294        // Root can write anywhere (e.g. in containers); then it installs.
295        if let Err(error) = result {
296            assert!(error.contains("is not writable"), "{error}");
297        }
298
299        // cargo's copy is cargo's to update.
300        let cargo = tmp.path().join(".cargo/bin");
301        fs::create_dir_all(&cargo).unwrap();
302        executable(&cargo.join("rigtop"), "#!/bin/sh\n");
303        let command = format!("{}/rigtop --agent", cargo.display());
304        let error = run(&update_script("1.2.3", &command)).unwrap_err();
305        assert!(
306            error.contains("cargo install rigtop --version 1.2.3"),
307            "{error}"
308        );
309
310        let error = run(&update_script("1.2.3", "/nowhere/rigtop --agent")).unwrap_err();
311        assert!(error.contains("not found"), "{error}");
312    }
313}