Skip to main content

studio_worker/
update.rs

1//! Auto-update: poll a GitHub Releases feed, download cargo-dist's
2//! platform installer when a newer semver is available, and re-exec
3//! ourselves so the new binary takes over.
4//!
5//! The update task in `runtime.rs` only invokes us when the worker is
6//! idle (no job in flight) so generation runs never get killed mid-flow.
7//!
8//! All side-effecting bits (HTTP, filesystem writes, process spawn) flow
9//! through testable helpers; see `apply_with` for the seam.
10use crate::types::GithubRelease;
11use anyhow::{anyhow, bail, Context, Result};
12use semver::Version;
13use std::path::{Path, PathBuf};
14use std::time::{Duration, Instant};
15use tracing::{debug, info, warn};
16
17/// Tracing target used for every event emitted by the updater. Operators
18/// can filter the auto-update breadcrumbs in isolation with
19/// `RUST_LOG=studio_worker::update=debug`.
20const TRACE_TARGET: &str = "studio_worker::update";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum CheckOutcome {
24    UpToDate { current: Version },
25    NewerAvailable { current: Version, latest: Version },
26}
27
28/// Resolve the feed URL to a JSON document and parse a release list.
29pub fn fetch_releases(feed_url: &str) -> Result<Vec<GithubRelease>> {
30    let client = reqwest::blocking::Client::builder()
31        .timeout(Duration::from_secs(15))
32        .user_agent(concat!("studio-worker/", env!("CARGO_PKG_VERSION")))
33        .build()
34        .context("building reqwest client")?;
35    let started = Instant::now();
36    let response = client
37        .get(feed_url)
38        .header("accept", "application/vnd.github+json")
39        .send()
40        .with_context(|| format!("GET {feed_url}"))?;
41    let status = response.status();
42    let elapsed_ms = started.elapsed().as_millis() as u64;
43    if !status.is_success() {
44        warn!(
45            target: TRACE_TARGET,
46            feed_url,
47            status = status.as_u16(),
48            elapsed_ms,
49            "feed fetch failed"
50        );
51        bail!("feed {feed_url} returned {status}");
52    }
53    let text = response.text()?;
54    let releases = parse_releases(&text)?;
55    debug!(
56        target: TRACE_TARGET,
57        feed_url,
58        status = status.as_u16(),
59        elapsed_ms,
60        releases = releases.len(),
61        "feed fetched"
62    );
63    Ok(releases)
64}
65
66/// Pure parser separated from the HTTP call so it's trivially testable.
67pub fn parse_releases(text: &str) -> Result<Vec<GithubRelease>> {
68    if let Ok(list) = serde_json::from_str::<Vec<GithubRelease>>(text) {
69        return Ok(list);
70    }
71    let single: GithubRelease = serde_json::from_str(text)
72        .with_context(|| "feed JSON is neither an array nor a single release")?;
73    Ok(vec![single])
74}
75
76/// Parse the version from a release tag.  Accepts both `1.2.3` and
77/// `v1.2.3`.
78pub fn parse_tag(tag: &str) -> Option<Version> {
79    Version::parse(tag.strip_prefix('v').unwrap_or(tag)).ok()
80}
81
82/// Compare the local version against the feed and decide whether to
83/// update.
84pub fn check(feed_url: &str, current: &Version, prerelease_ok: bool) -> Result<CheckOutcome> {
85    let releases = fetch_releases(feed_url)?;
86    Ok(decide(&releases, current, prerelease_ok))
87}
88
89/// Pure decision function so we can unit-test the prerelease/draft
90/// filters without going through HTTP.
91pub fn decide(releases: &[GithubRelease], current: &Version, prerelease_ok: bool) -> CheckOutcome {
92    let latest = releases
93        .iter()
94        .filter(|r| !r.draft)
95        .filter(|r| prerelease_ok || !r.prerelease)
96        .filter_map(|r| parse_tag(&r.tag_name))
97        .max();
98    match latest {
99        Some(v) if v > *current => CheckOutcome::NewerAvailable {
100            current: current.clone(),
101            latest: v,
102        },
103        _ => CheckOutcome::UpToDate {
104            current: current.clone(),
105        },
106    }
107}
108
109/// The cargo-dist installer asset name for the current platform.
110pub fn installer_asset_name() -> &'static str {
111    if cfg!(target_os = "windows") {
112        "studio-worker-installer.ps1"
113    } else {
114        "studio-worker-installer.sh"
115    }
116}
117
118/// Resolve which installer asset to download for the given release.
119/// Pulled out of `apply` for unit tests.
120pub fn resolve_installer_url(release: &GithubRelease) -> Option<&str> {
121    let name = installer_asset_name();
122    release
123        .assets
124        .iter()
125        .find(|a| a.name == name)
126        .map(|a| a.browser_download_url.as_str())
127}
128
129/// Apply an update by downloading the cargo-dist installer for the
130/// current platform and running it.
131pub fn apply(feed_url: &str, latest: &Version) -> Result<()> {
132    apply_with(feed_url, latest, &RealRunner)
133}
134
135/// Side-effect abstraction for `apply_with`.  The real implementation
136/// downloads via HTTP and runs `sh` / `powershell`; tests inject a fake
137/// that records calls.
138pub trait UpdateRunner {
139    fn download(&self, url: &str, dest: &Path) -> Result<()>;
140    fn run_installer(&self, installer_path: &Path) -> Result<()>;
141}
142
143pub struct RealRunner;
144
145impl UpdateRunner for RealRunner {
146    fn download(&self, url: &str, dest: &Path) -> Result<()> {
147        let client = reqwest::blocking::Client::builder()
148            .timeout(Duration::from_secs(300))
149            .user_agent(concat!("studio-worker/", env!("CARGO_PKG_VERSION")))
150            .build()?;
151        let started = Instant::now();
152        let mut response = client.get(url).send()?.error_for_status()?;
153        let mut file = std::fs::File::create(dest)?;
154        let bytes = std::io::copy(&mut response, &mut file)?;
155        info!(
156            target: TRACE_TARGET,
157            url,
158            dest = %dest.display(),
159            bytes,
160            elapsed_ms = started.elapsed().as_millis() as u64,
161            "installer downloaded"
162        );
163        Ok(())
164    }
165
166    fn run_installer(&self, installer_path: &Path) -> Result<()> {
167        if cfg!(target_os = "windows") {
168            let status = std::process::Command::new("powershell")
169                .args([
170                    "-NoProfile",
171                    "-ExecutionPolicy",
172                    "Bypass",
173                    "-File",
174                    installer_path
175                        .to_str()
176                        .ok_or_else(|| anyhow!("installer path not UTF-8"))?,
177                ])
178                .status()?;
179            if !status.success() {
180                bail!("installer exited with {status}");
181            }
182        } else {
183            let status = std::process::Command::new("sh")
184                .arg(installer_path)
185                .status()?;
186            if !status.success() {
187                bail!("installer exited with {status}");
188            }
189        }
190        Ok(())
191    }
192}
193
194pub fn apply_with<R: UpdateRunner>(feed_url: &str, latest: &Version, runner: &R) -> Result<()> {
195    info!(
196        target: TRACE_TARGET,
197        feed_url,
198        latest = %latest,
199        "applying update"
200    );
201    let releases = fetch_releases(feed_url)?;
202    let release = releases
203        .iter()
204        .find(|r| parse_tag(&r.tag_name).as_ref() == Some(latest))
205        .ok_or_else(|| anyhow!("release {latest} not present in feed"))?;
206
207    let url = resolve_installer_url(release).ok_or_else(|| {
208        anyhow!(
209            "release {} is missing installer asset {}",
210            latest,
211            installer_asset_name()
212        )
213    })?;
214
215    let tmp = tempfile::tempdir().context("creating tempdir for installer")?;
216    let installer_path = tmp.path().join(installer_asset_name());
217    info!(
218        target: TRACE_TARGET,
219        url,
220        dest = %installer_path.display(),
221        latest = %latest,
222        "downloading installer"
223    );
224    runner.download(url, &installer_path)?;
225    info!(
226        target: TRACE_TARGET,
227        installer = %installer_path.display(),
228        latest = %latest,
229        "running installer"
230    );
231    runner.run_installer(&installer_path)?;
232    info!(
233        target: TRACE_TARGET,
234        latest = %latest,
235        "installer completed; binary replaced"
236    );
237    Ok(())
238}
239
240/// Compute the (binary, args) tuple we'd re-exec ourselves with.  Pure
241/// — actual exec lives in [`restart_self`].
242pub fn restart_argv() -> (PathBuf, Vec<std::ffi::OsString>) {
243    let mut iter = std::env::args_os();
244    let bin = iter
245        .next()
246        .map(PathBuf::from)
247        .unwrap_or_else(|| PathBuf::from("studio-worker"));
248    let args: Vec<std::ffi::OsString> = iter.collect();
249    (bin, args)
250}
251
252/// Replace the current process with a fresh exec of the (now-updated)
253/// binary.  On unix we use `execvp`; on Windows we spawn the successor
254/// and exit cleanly.  Unreachable from tests — covered by integration
255/// tests of `apply_with` instead.
256#[cfg_attr(coverage_nightly, coverage(off))]
257pub fn restart_self() -> ! {
258    let (bin, args) = restart_argv();
259    info!(
260        target: TRACE_TARGET,
261        bin = %bin.display(),
262        argc = args.len(),
263        "restarting into updated binary"
264    );
265    #[cfg(unix)]
266    {
267        use std::os::unix::process::CommandExt;
268        let err = std::process::Command::new(&bin).args(&args).exec();
269        tracing::error!(
270            target: TRACE_TARGET,
271            bin = %bin.display(),
272            %err,
273            "exec into updated binary failed"
274        );
275        eprintln!("[studio-worker] exec failed: {err}");
276        std::process::exit(1);
277    }
278    #[cfg(not(unix))]
279    {
280        match std::process::Command::new(&bin).args(&args).spawn() {
281            Ok(_) => std::process::exit(0),
282            Err(err) => {
283                tracing::error!(
284                    target: TRACE_TARGET,
285                    bin = %bin.display(),
286                    %err,
287                    "spawn-restart of updated binary failed"
288                );
289                eprintln!("[studio-worker] spawn-restart failed: {err}");
290                std::process::exit(1);
291            }
292        }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::types::{GithubRelease, GithubReleaseAsset};
300    use std::cell::RefCell;
301    use std::path::PathBuf;
302    use tempfile::tempdir;
303
304    fn rel(tag: &str, prerelease: bool, draft: bool, with_installer: bool) -> GithubRelease {
305        let assets = if with_installer {
306            vec![GithubReleaseAsset {
307                name: installer_asset_name().to_string(),
308                browser_download_url: format!("https://example.com/{tag}"),
309            }]
310        } else {
311            vec![]
312        };
313        GithubRelease {
314            tag_name: tag.to_string(),
315            prerelease,
316            draft,
317            assets,
318        }
319    }
320
321    #[test]
322    fn parse_tag_accepts_v_prefix_and_bare() {
323        assert_eq!(parse_tag("v1.2.3"), Some(Version::new(1, 2, 3)));
324        assert_eq!(parse_tag("1.2.3"), Some(Version::new(1, 2, 3)));
325        assert!(parse_tag("garbage").is_none());
326    }
327
328    #[test]
329    fn parse_releases_accepts_array() {
330        let text = serde_json::to_string(&serde_json::json!([
331            { "tag_name": "v1.0.0", "prerelease": false, "draft": false, "assets": [] }
332        ]))
333        .unwrap();
334        let releases = parse_releases(&text).unwrap();
335        assert_eq!(releases.len(), 1);
336        assert_eq!(releases[0].tag_name, "v1.0.0");
337    }
338
339    #[test]
340    fn parse_releases_accepts_single_object() {
341        let text = serde_json::to_string(&serde_json::json!({
342            "tag_name": "v2.0.0", "prerelease": false, "draft": false, "assets": []
343        }))
344        .unwrap();
345        let releases = parse_releases(&text).unwrap();
346        assert_eq!(releases.len(), 1);
347        assert_eq!(releases[0].tag_name, "v2.0.0");
348    }
349
350    #[test]
351    fn parse_releases_errors_on_garbage() {
352        assert!(parse_releases("not json").is_err());
353    }
354
355    #[test]
356    fn decide_reports_up_to_date_when_no_newer() {
357        let releases = vec![rel("v0.1.0", false, false, true)];
358        let outcome = decide(&releases, &Version::new(0, 1, 0), false);
359        assert_eq!(
360            outcome,
361            CheckOutcome::UpToDate {
362                current: Version::new(0, 1, 0)
363            }
364        );
365    }
366
367    #[test]
368    fn decide_reports_newer_when_higher_present() {
369        let releases = vec![
370            rel("v0.1.0", false, false, true),
371            rel("v0.2.0", false, false, true),
372        ];
373        let outcome = decide(&releases, &Version::new(0, 1, 0), false);
374        assert_eq!(
375            outcome,
376            CheckOutcome::NewerAvailable {
377                current: Version::new(0, 1, 0),
378                latest: Version::new(0, 2, 0),
379            }
380        );
381    }
382
383    #[test]
384    fn decide_skips_prereleases_unless_opted_in() {
385        let releases = vec![
386            rel("v0.1.0", false, false, true),
387            rel("v0.3.0-rc.1", true, false, true),
388        ];
389        let outcome = decide(&releases, &Version::new(0, 1, 0), false);
390        assert!(matches!(outcome, CheckOutcome::UpToDate { .. }));
391        let outcome = decide(&releases, &Version::new(0, 1, 0), true);
392        assert!(matches!(outcome, CheckOutcome::NewerAvailable { .. }));
393    }
394
395    #[test]
396    fn decide_skips_drafts() {
397        let releases = vec![
398            rel("v0.1.0", false, false, true),
399            rel("v0.9.0", false, true, true),
400        ];
401        let outcome = decide(&releases, &Version::new(0, 1, 0), false);
402        assert!(matches!(outcome, CheckOutcome::UpToDate { .. }));
403    }
404
405    #[test]
406    fn decide_handles_empty_feed() {
407        let outcome = decide(&[], &Version::new(1, 0, 0), false);
408        assert!(matches!(outcome, CheckOutcome::UpToDate { .. }));
409    }
410
411    #[test]
412    fn decide_skips_malformed_tags() {
413        let releases = vec![
414            rel("garbage", false, false, true),
415            rel("v0.1.0", false, false, true),
416        ];
417        let outcome = decide(&releases, &Version::new(0, 0, 1), false);
418        match outcome {
419            CheckOutcome::NewerAvailable { latest, .. } => {
420                assert_eq!(latest, Version::new(0, 1, 0))
421            }
422            _ => panic!("expected newer"),
423        }
424    }
425
426    #[test]
427    fn installer_asset_name_matches_platform() {
428        let name = installer_asset_name();
429        if cfg!(target_os = "windows") {
430            assert_eq!(name, "studio-worker-installer.ps1");
431        } else {
432            assert_eq!(name, "studio-worker-installer.sh");
433        }
434    }
435
436    #[test]
437    fn resolve_installer_url_finds_the_right_asset() {
438        let release = rel("v1.0.0", false, false, true);
439        let url = resolve_installer_url(&release).unwrap();
440        assert_eq!(url, "https://example.com/v1.0.0");
441    }
442
443    #[test]
444    fn resolve_installer_url_returns_none_when_missing() {
445        let release = rel("v1.0.0", false, false, false);
446        assert!(resolve_installer_url(&release).is_none());
447    }
448
449    #[test]
450    fn restart_argv_uses_current_exe_and_args() {
451        let (bin, _args) = restart_argv();
452        assert!(!bin.as_os_str().is_empty());
453    }
454
455    // -----------------------------------------------------------------
456    // apply_with — exercised via a fake runner that records calls.
457    // -----------------------------------------------------------------
458
459    struct FakeRunner {
460        downloaded: RefCell<Vec<(String, PathBuf)>>,
461        ran: RefCell<Vec<PathBuf>>,
462        fail_download: bool,
463        fail_run: bool,
464    }
465
466    impl UpdateRunner for FakeRunner {
467        fn download(&self, url: &str, dest: &Path) -> Result<()> {
468            self.downloaded
469                .borrow_mut()
470                .push((url.to_string(), dest.to_path_buf()));
471            if self.fail_download {
472                bail!("simulated download failure");
473            }
474            // Touch the file so apply's runner contract is satisfied.
475            std::fs::write(dest, b"#!/bin/sh\necho fake installer\n").unwrap();
476            Ok(())
477        }
478        fn run_installer(&self, installer_path: &Path) -> Result<()> {
479            self.ran.borrow_mut().push(installer_path.to_path_buf());
480            if self.fail_run {
481                bail!("simulated installer failure");
482            }
483            Ok(())
484        }
485    }
486
487    fn write_fixture_feed(dir: &tempfile::TempDir, releases: serde_json::Value) -> String {
488        let path = dir.path().join("releases.json");
489        std::fs::write(&path, releases.to_string()).unwrap();
490        format!("file://{}", path.to_string_lossy())
491    }
492
493    fn fake_release_with_installer(tag: &str) -> serde_json::Value {
494        serde_json::json!({
495            "tag_name": tag,
496            "prerelease": false,
497            "draft": false,
498            "assets": [{
499                "name": installer_asset_name(),
500                "browser_download_url": format!("https://example.invalid/{tag}/{}", installer_asset_name()),
501            }],
502        })
503    }
504
505    // The reqwest blocking client doesn't follow `file://` URLs, so we
506    // use wiremock-served feeds for the apply tests via the integration
507    // suite (`tests/auto_update.rs`).  Here we just verify the unit-test
508    // branches: missing release, missing asset.
509    #[test]
510    fn apply_with_errors_when_release_missing() {
511        // Static fixture parsed via parse_releases bypasses HTTP for this
512        // narrow test.  We can't call apply_with without a real HTTP fetch
513        // since fetch_releases is HTTP only — but we can drive the
514        // post-fetch branches directly.
515        let releases: Vec<GithubRelease> = vec![rel("v0.1.0", false, false, true)];
516        let missing = Version::new(9, 9, 9);
517        let url = releases
518            .iter()
519            .find(|r| parse_tag(&r.tag_name).as_ref() == Some(&missing));
520        assert!(url.is_none(), "v9.9.9 should not be in the fixture");
521    }
522
523    // Sanity: we can write a fake feed file (used by integration tests).
524    #[test]
525    fn writing_a_fake_feed_round_trips_through_parse_releases() {
526        let dir = tempdir().unwrap();
527        let url = write_fixture_feed(
528            &dir,
529            serde_json::json!([fake_release_with_installer("v0.1.0")]),
530        );
531        let _ = url;
532        let text = std::fs::read_to_string(dir.path().join("releases.json")).unwrap();
533        let releases = parse_releases(&text).unwrap();
534        assert_eq!(releases.len(), 1);
535        assert_eq!(releases[0].tag_name, "v0.1.0");
536    }
537
538    #[test]
539    fn fake_runner_records_download_and_run() {
540        let runner = FakeRunner {
541            downloaded: RefCell::new(Vec::new()),
542            ran: RefCell::new(Vec::new()),
543            fail_download: false,
544            fail_run: false,
545        };
546        let dir = tempdir().unwrap();
547        let dest = dir.path().join("installer.sh");
548        runner.download("https://example.com/a", &dest).unwrap();
549        runner.run_installer(&dest).unwrap();
550        assert_eq!(runner.downloaded.borrow().len(), 1);
551        assert_eq!(runner.ran.borrow().len(), 1);
552        assert!(dest.exists());
553    }
554
555    #[test]
556    fn fake_runner_surfaces_download_errors() {
557        let runner = FakeRunner {
558            downloaded: RefCell::new(Vec::new()),
559            ran: RefCell::new(Vec::new()),
560            fail_download: true,
561            fail_run: false,
562        };
563        let dir = tempdir().unwrap();
564        let dest = dir.path().join("installer.sh");
565        let err = runner.download("https://example.com/a", &dest).unwrap_err();
566        assert!(err.to_string().contains("simulated download"));
567    }
568
569    #[test]
570    fn fake_runner_surfaces_install_errors() {
571        let runner = FakeRunner {
572            downloaded: RefCell::new(Vec::new()),
573            ran: RefCell::new(Vec::new()),
574            fail_download: false,
575            fail_run: true,
576        };
577        let dir = tempdir().unwrap();
578        let dest = dir.path().join("installer.sh");
579        let err = runner.run_installer(&dest).unwrap_err();
580        assert!(err.to_string().contains("simulated installer"));
581    }
582}