spin_plugins/badger/
mod.rs

1mod store;
2
3use std::io::IsTerminal;
4
5use self::store::{BadgerRecordManager, PreviousBadger};
6use crate::manifest::PluginManifest;
7
8const BADGER_TIMEOUT_DAYS: i64 = 14;
9
10// How the checker works:
11//
12// * The consumer calls BadgerChecker::start().  This immediately returns a task handle to
13//   the checker.  It's important that this be immediate, because it's called on _every_
14//   plugin invocation and we don't want to slow that down.
15// * In the background task, the checker determines if it needs to update the local copy
16//   of the plugins registry.  If so, it kicks that off as a background process.
17//   * The checker may determine while running the task that the user should not be prompted,
18//     or hit an error trying to kick things off the check.  In this case, it returns
19//     BadgerChecker::Precomputed from the task, ready to be picked up.
20//   * Otherwise, the checker wants to wait as long as possible before determining whether
21//     an upgrade is possible.  In this case it returns BadgerChecker::Deferred from the task.
22//     This captures the information needed for the upgrade check.
23// * When the consumer is ready to find out if it needs to notify the user, it awaits
24//   the task handle.  This should still be quick.
25// * The consumer then calls BadgerChecker::check().
26//   * If the task returned Precomputed (i.e. the task reached a decision before exiting),
27//     check() returns that precomputed value.
28//   * If the task returned Deferred (i.e. the task was holding off to let the background registry
29//     update do its work), it now loads the local copy of the registry, and compares the
30//     available versions to the current version.
31//
32// The reason for the Precomputed/Deferred dance is to handle the two cases of:
33// 1. There's no point waiting and doing the calculations because we _know_ we have a decision (or an error).
34// 2. There's a point to waiting because there _might_ be an upgrade, so we want to give the background
35//    process as much time as possible to complete, so we can offer the latest upgrade.
36
37pub enum BadgerChecker {
38    Precomputed(anyhow::Result<BadgerUI>),
39    Deferred(BadgerEvaluator),
40}
41
42pub struct BadgerEvaluator {
43    plugin_name: String,
44    current_version: semver::Version,
45    spin_version: &'static str,
46    plugin_manager: crate::manager::PluginManager,
47    record_manager: BadgerRecordManager,
48    previous_badger: PreviousBadger,
49}
50
51impl BadgerChecker {
52    pub fn start(
53        name: &str,
54        current_version: Option<String>,
55        spin_version: &'static str,
56    ) -> tokio::task::JoinHandle<Self> {
57        let name = name.to_owned();
58
59        tokio::task::spawn(async move {
60            let current_version = match current_version {
61                Some(v) => v.to_owned(),
62                None => return Self::Precomputed(Ok(BadgerUI::None)),
63            };
64
65            if !std::io::stderr().is_terminal() {
66                return Self::Precomputed(Ok(BadgerUI::None));
67            }
68
69            match BadgerEvaluator::new(&name, &current_version, spin_version).await {
70                Ok(b) => {
71                    if b.should_check() {
72                        // We want to offer the user an upgrade if one is available. Kick off a
73                        // background process to update the local copy of the registry, and
74                        // return the case that causes Self::check() to consult the registry.
75                        BadgerEvaluator::fire_and_forget_update();
76                        Self::Deferred(b)
77                    } else {
78                        // We do not want to offer the user an upgrade, e.g. because we have
79                        // badgered them quite recently. Stash this decision for Self::check()
80                        // to return.
81                        Self::Precomputed(Ok(BadgerUI::None))
82                    }
83                }
84                Err(e) => {
85                    // We hit a problem determining if we wanted to offer an upgrade or not.
86                    // Stash the error for Self::check() to return.
87                    Self::Precomputed(Err(e))
88                }
89            }
90        })
91    }
92
93    pub async fn check(self) -> anyhow::Result<BadgerUI> {
94        match self {
95            Self::Precomputed(r) => r,
96            Self::Deferred(b) => b.check().await,
97        }
98    }
99}
100
101impl BadgerEvaluator {
102    async fn new(
103        name: &str,
104        current_version: &str,
105        spin_version: &'static str,
106    ) -> anyhow::Result<Self> {
107        let current_version = semver::Version::parse(current_version)?;
108        let plugin_manager = crate::manager::PluginManager::try_default()?;
109        let record_manager = BadgerRecordManager::default()?;
110        let previous_badger = record_manager.previous_badger(name, &current_version).await;
111
112        Ok(Self {
113            plugin_name: name.to_owned(),
114            current_version,
115            spin_version,
116            plugin_manager,
117            record_manager,
118            previous_badger,
119        })
120    }
121
122    fn should_check(&self) -> bool {
123        match self.previous_badger {
124            PreviousBadger::Fresh => true,
125            PreviousBadger::FromCurrent { when, .. } => has_timeout_expired(when),
126        }
127    }
128
129    fn fire_and_forget_update() {
130        if let Err(e) = Self::fire_and_forget_update_impl() {
131            tracing::info!("Failed to launch plugins update process; checking using latest local repo anyway. Error: {e:#}");
132        }
133    }
134
135    fn fire_and_forget_update_impl() -> anyhow::Result<()> {
136        let mut update_cmd = tokio::process::Command::new(std::env::current_exe()?);
137        update_cmd.args(["plugins", "update"]);
138        update_cmd.stdout(std::process::Stdio::null());
139        update_cmd.stderr(std::process::Stdio::null());
140        update_cmd.spawn()?;
141        Ok(())
142    }
143
144    async fn check(&self) -> anyhow::Result<BadgerUI> {
145        let available_upgrades = self.available_upgrades().await?;
146
147        // TO CONSIDER: skipping this check and badgering for the same upgrade in case they missed it
148        if self
149            .previous_badger
150            .includes_any(&available_upgrades.list())
151        {
152            return Ok(BadgerUI::None);
153        }
154
155        if !available_upgrades.is_none() {
156            self.record_manager
157                .record_badger(
158                    &self.plugin_name,
159                    &self.current_version,
160                    &available_upgrades.list(),
161                )
162                .await
163        };
164
165        Ok(available_upgrades.classify())
166    }
167
168    async fn available_upgrades(&self) -> anyhow::Result<AvailableUpgrades> {
169        let store = self.plugin_manager.store();
170
171        let latest_version = {
172            let latest_lookup = crate::lookup::PluginLookup::new(&self.plugin_name, None);
173            let latest_manifest = latest_lookup
174                .resolve_manifest_exact(store.get_plugins_directory())
175                .await
176                .ok();
177            latest_manifest.and_then(|m| semver::Version::parse(m.version()).ok())
178        };
179
180        let manifests = store.catalogue_manifests()?;
181        let relevant_manifests = manifests
182            .into_iter()
183            .filter(|m| m.name() == self.plugin_name);
184        let compatible_manifests = relevant_manifests.filter(|m| {
185            m.has_compatible_package() && m.is_compatible_spin_version(self.spin_version)
186        });
187        let compatible_plugin_versions =
188            compatible_manifests.filter_map(|m| PluginVersion::try_from(m, &latest_version));
189        let considerable_manifests = compatible_plugin_versions
190            .filter(|pv| !pv.is_prerelease() && pv.is_higher_than(&self.current_version))
191            .collect::<Vec<_>>();
192
193        let (eligible_manifests, questionable_manifests) = if self.current_version.major == 0 {
194            (vec![], considerable_manifests)
195        } else {
196            considerable_manifests
197                .into_iter()
198                .partition(|pv| pv.version.major == self.current_version.major)
199        };
200
201        let highest_eligible_manifest = eligible_manifests
202            .into_iter()
203            .max_by_key(|pv| pv.version.clone());
204        let highest_questionable_manifest = questionable_manifests
205            .into_iter()
206            .max_by_key(|pv| pv.version.clone());
207
208        Ok(AvailableUpgrades {
209            eligible: highest_eligible_manifest,
210            questionable: highest_questionable_manifest,
211        })
212    }
213}
214
215fn has_timeout_expired(from_time: chrono::DateTime<chrono::Utc>) -> bool {
216    let timeout = chrono::Duration::try_days(BADGER_TIMEOUT_DAYS).unwrap();
217    let now = chrono::Utc::now();
218    match now.checked_sub_signed(timeout) {
219        None => true,
220        Some(t) => from_time < t,
221    }
222}
223
224pub struct AvailableUpgrades {
225    eligible: Option<PluginVersion>,
226    questionable: Option<PluginVersion>,
227}
228
229impl AvailableUpgrades {
230    fn is_none(&self) -> bool {
231        self.eligible.is_none() && self.questionable.is_none()
232    }
233
234    fn classify(&self) -> BadgerUI {
235        match (&self.eligible, &self.questionable) {
236            (None, None) => BadgerUI::None,
237            (Some(e), None) => BadgerUI::Eligible(e.clone()),
238            (None, Some(q)) => BadgerUI::Questionable(q.clone()),
239            (Some(e), Some(q)) => BadgerUI::Both {
240                eligible: e.clone(),
241                questionable: q.clone(),
242            },
243        }
244    }
245
246    fn list(&self) -> Vec<&semver::Version> {
247        [self.eligible.as_ref(), self.questionable.as_ref()]
248            .iter()
249            .filter_map(|pv| pv.as_ref())
250            .map(|pv| &pv.version)
251            .collect()
252    }
253}
254
255#[derive(Clone, Debug)]
256pub struct PluginVersion {
257    version: semver::Version,
258    name: String,
259    is_latest: bool,
260}
261
262impl PluginVersion {
263    fn try_from(manifest: PluginManifest, latest: &Option<semver::Version>) -> Option<Self> {
264        match semver::Version::parse(manifest.version()) {
265            Ok(version) => {
266                let name = manifest.name();
267                let is_latest = match latest {
268                    None => false,
269                    Some(latest) => &version == latest,
270                };
271                Some(Self {
272                    version,
273                    name,
274                    is_latest,
275                })
276            }
277            Err(_) => None,
278        }
279    }
280
281    fn is_prerelease(&self) -> bool {
282        !self.version.pre.is_empty()
283    }
284
285    fn is_higher_than(&self, other: &semver::Version) -> bool {
286        &self.version > other
287    }
288
289    pub fn upgrade_command(&self) -> String {
290        if self.is_latest {
291            format!("spin plugins upgrade {}", self.name)
292        } else {
293            format!("spin plugins upgrade {} -v {}", self.name, self.version)
294        }
295    }
296}
297
298impl std::fmt::Display for PluginVersion {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        write!(f, "{}", self.version)
301    }
302}
303
304pub enum BadgerUI {
305    // Do not badger the user. There is no available upgrade, or we have already badgered
306    // them recently about this plugin.
307    None,
308    // There is an available upgrade which is compatible (same non-zero major version).
309    Eligible(PluginVersion),
310    // There is an available upgrade but it may not be compatible (different major version
311    // or major version is zero).
312    Questionable(PluginVersion),
313    // There is an available upgrade which is compatible, but there is also an even more
314    // recent upgrade which may not be compatible.
315    Both {
316        eligible: PluginVersion,
317        questionable: PluginVersion,
318    },
319}