Skip to main content

apollo/
self_update.rs

1//! Self-update support for repository-backed local installs.
2
3use std::path::{Path, PathBuf};
4
5use crate::config::SelfUpdateConfig;
6
7#[derive(Debug, Clone)]
8pub struct SelfUpdater {
9    workspace: PathBuf,
10    config: SelfUpdateConfig,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum UpdateOutcome {
15    NoRepo,
16    Disabled,
17    DirtyWorktree,
18    AlreadyCurrent,
19    Updated { restarted: bool },
20}
21
22impl SelfUpdater {
23    pub fn new(workspace: PathBuf, config: SelfUpdateConfig) -> Self {
24        Self { workspace, config }
25    }
26
27    pub fn start(self) -> tokio::task::JoinHandle<()> {
28        tokio::spawn(self.run_loop())
29    }
30
31    async fn run_loop(self) {
32        if !self.config.enabled {
33            tracing::info!("self-update disabled");
34            return;
35        }
36
37        let mut interval =
38            tokio::time::interval(tokio::time::Duration::from_secs(self.config.interval_secs));
39        interval.tick().await;
40
41        loop {
42            interval.tick().await;
43            match self.run_once().await {
44                Ok(UpdateOutcome::Updated { restarted }) => {
45                    tracing::info!(restarted, "self-update applied");
46                }
47                Ok(UpdateOutcome::AlreadyCurrent) => {
48                    tracing::debug!("self-update: already current");
49                }
50                Ok(UpdateOutcome::DirtyWorktree) => {
51                    tracing::warn!("self-update: skipping dirty worktree");
52                }
53                Ok(UpdateOutcome::NoRepo) => {
54                    tracing::debug!("self-update: workspace is not a git repo");
55                }
56                Ok(UpdateOutcome::Disabled) => break,
57                Err(error) => {
58                    tracing::warn!(error = %error, "self-update failed");
59                }
60            }
61        }
62    }
63
64    pub async fn run_once(&self) -> anyhow::Result<UpdateOutcome> {
65        if !self.config.enabled {
66            return Ok(UpdateOutcome::Disabled);
67        }
68
69        if !is_git_repo(&self.workspace).await? {
70            return Ok(UpdateOutcome::NoRepo);
71        }
72
73        if !worktree_is_clean(&self.workspace).await? {
74            return Ok(UpdateOutcome::DirtyWorktree);
75        }
76
77        git_fetch(&self.workspace, &self.config.remote, &self.config.branch).await?;
78
79        if current_head(&self.workspace).await?
80            == upstream_head(&self.workspace, &self.config).await?
81        {
82            return Ok(UpdateOutcome::AlreadyCurrent);
83        }
84
85        git_fast_forward(&self.workspace, &self.config.remote, &self.config.branch).await?;
86        cargo_build_release(&self.workspace).await?;
87        let restarted = maybe_restart_service(&self.workspace, &self.config).await?;
88
89        Ok(UpdateOutcome::Updated { restarted })
90    }
91}
92
93async fn is_git_repo(workspace: &Path) -> anyhow::Result<bool> {
94    let output = run_git(workspace, &["rev-parse", "--is-inside-work-tree"]).await?;
95    Ok(output.trim() == "true")
96}
97
98async fn worktree_is_clean(workspace: &Path) -> anyhow::Result<bool> {
99    let output = run_git(workspace, &["status", "--porcelain"]).await?;
100    Ok(output.trim().is_empty())
101}
102
103async fn git_fetch(workspace: &Path, remote: &str, branch: &str) -> anyhow::Result<()> {
104    let _ = run_git(workspace, &["fetch", remote, branch, "--quiet"]).await?;
105    Ok(())
106}
107
108async fn current_head(workspace: &Path) -> anyhow::Result<String> {
109    run_git(workspace, &["rev-parse", "HEAD"]).await
110}
111
112async fn upstream_head(workspace: &Path, config: &SelfUpdateConfig) -> anyhow::Result<String> {
113    run_git(
114        workspace,
115        &[
116            "rev-parse",
117            &format!("refs/remotes/{}/{}", config.remote, config.branch),
118        ],
119    )
120    .await
121}
122
123async fn git_fast_forward(workspace: &Path, remote: &str, branch: &str) -> anyhow::Result<()> {
124    let _ = run_git(
125        workspace,
126        &["merge", "--ff-only", &format!("{}/{}", remote, branch)],
127    )
128    .await?;
129    Ok(())
130}
131
132async fn cargo_build_release(workspace: &Path) -> anyhow::Result<()> {
133    let output = tokio::process::Command::new("cargo")
134        .arg("build")
135        .arg("--release")
136        .current_dir(workspace)
137        .output()
138        .await?;
139
140    if !output.status.success() {
141        anyhow::bail!(
142            "cargo build --release failed: {}",
143            String::from_utf8_lossy(&output.stderr)
144        );
145    }
146
147    Ok(())
148}
149
150async fn maybe_restart_service(
151    workspace: &Path,
152    config: &SelfUpdateConfig,
153) -> anyhow::Result<bool> {
154    let Some(service) = &config.restart_service else {
155        return Ok(false);
156    };
157
158    let output = tokio::process::Command::new("systemctl")
159        .arg("--user")
160        .arg("restart")
161        .arg(service)
162        .current_dir(workspace)
163        .output()
164        .await?;
165
166    if output.status.success() {
167        return Ok(true);
168    }
169
170    tracing::warn!(
171        service = %service,
172        stderr = %String::from_utf8_lossy(&output.stderr),
173        "self-update build succeeded but service restart failed"
174    );
175    Ok(false)
176}
177
178async fn run_git(workspace: &Path, args: &[&str]) -> anyhow::Result<String> {
179    let output = tokio::process::Command::new("git")
180        .args(args)
181        .current_dir(workspace)
182        .output()
183        .await?;
184
185    if !output.status.success() {
186        anyhow::bail!(
187            "git {} failed: {}",
188            args.join(" "),
189            String::from_utf8_lossy(&output.stderr)
190        );
191    }
192
193    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
194}