diff --git a/Cargo.lock b/Cargo.lock index 428eff5f1..c9f7c7dfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5342,8 +5342,10 @@ version = "1.4.0-pre0" dependencies = [ "anyhow", "bytes", + "chrono", "dirs 4.0.0", "flate2", + "is-terminal", "reqwest", "semver 1.0.16", "serde", diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index 7d3665ec2..e9f5b7991 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -7,8 +7,10 @@ edition = { workspace = true } [dependencies] anyhow = "1.0" bytes = "1.1" +chrono = "0.4" dirs = "4.0" flate2 = "1.0" +is-terminal = "0.4" reqwest = { version = "0.11", features = ["json"] } semver = "1.0" serde = { version = "1.0", features = ["derive"] } diff --git a/crates/plugins/src/badger/mod.rs b/crates/plugins/src/badger/mod.rs new file mode 100644 index 000000000..fe5ffaf94 --- /dev/null +++ b/crates/plugins/src/badger/mod.rs @@ -0,0 +1,318 @@ +mod store; + +use self::store::{BadgerRecordManager, PreviousBadger}; +use crate::manifest::PluginManifest; +use is_terminal::IsTerminal; + +const BADGER_TIMEOUT_DAYS: i64 = 14; + +// How the checker works: +// +// * The consumer calls BadgerChecker::start(). This immediately returns a task handle to +// the checker. It's important that this be immediate, because it's called on _every_ +// plugin invocation and we don't want to slow that down. +// * In the background task, the checker determines if it needs to update the local copy +// of the plugins registry. If so, it kicks that off as a background process. +// * The checker may determine while running the task that the user should not be prompted, +// or hit an error trying to kick things off the check. In this case, it returns +// BadgerChecker::Precomputed from the task, ready to be picked up. +// * Otherwise, the checker wants to wait as long as possible before determining whether +// an upgrade is possible. In this case it returns BadgerChecker::Deferred from the task. +// This captures the information needed for the upgrade check. +// * When the consumer is ready to find out if it needs to notify the user, it awaits +// the task handle. This should still be quick. +// * The consumer then calls BadgerChecker::check(). +// * If the task returned Precomputed (i.e. the task reached a decision before exiting), +// check() returns that precomputed value. +// * If the task returned Deferred (i.e. the task was holding off to let the background registry +// update do its work), it now loads the local copy of the registry, and compares the +// available versions to the current version. +// +// The reason for the Precomputed/Deferred dance is to handle the two cases of: +// 1. There's no point waiting and doing the calculations because we _know_ we have a decision (or an error). +// 2. There's a point to waiting because there _might_ be an upgrade, so we want to give the background +// process as much time as possible to complete, so we can offer the latest upgrade. + +pub enum BadgerChecker { + Precomputed(anyhow::Result), + Deferred(BadgerEvaluator), +} + +pub struct BadgerEvaluator { + plugin_name: String, + current_version: semver::Version, + spin_version: &'static str, + plugin_manager: crate::manager::PluginManager, + record_manager: BadgerRecordManager, + previous_badger: PreviousBadger, +} + +impl BadgerChecker { + pub fn start( + name: &str, + current_version: Option, + spin_version: &'static str, + ) -> tokio::task::JoinHandle { + let name = name.to_owned(); + + tokio::task::spawn(async move { + let current_version = match current_version { + Some(v) => v.to_owned(), + None => return Self::Precomputed(Ok(BadgerUI::None)), + }; + + if !std::io::stderr().is_terminal() { + return Self::Precomputed(Ok(BadgerUI::None)); + } + + match BadgerEvaluator::new(&name, ¤t_version, spin_version).await { + Ok(b) => { + if b.should_check() { + // We want to offer the user an upgrade if one is available. Kick off a + // background process to update the local copy of the registry, and + // return the case that causes Self::check() to consult the registry. + BadgerEvaluator::fire_and_forget_update(); + Self::Deferred(b) + } else { + // We do not want to offer the user an upgrade, e.g. because we have + // badgered them quite recently. Stash this decision for Self::check() + // to return. + Self::Precomputed(Ok(BadgerUI::None)) + } + } + Err(e) => { + // We hit a problem determining if we wanted to offer an upgrade or not. + // Stash the error for Self::check() to return. + Self::Precomputed(Err(e)) + } + } + }) + } + + pub async fn check(self) -> anyhow::Result { + match self { + Self::Precomputed(r) => r, + Self::Deferred(b) => b.check().await, + } + } +} + +impl BadgerEvaluator { + async fn new( + name: &str, + current_version: &str, + spin_version: &'static str, + ) -> anyhow::Result { + let current_version = semver::Version::parse(current_version)?; + let plugin_manager = crate::manager::PluginManager::try_default()?; + let record_manager = BadgerRecordManager::default()?; + let previous_badger = record_manager.previous_badger(name, ¤t_version).await; + + Ok(Self { + plugin_name: name.to_owned(), + current_version, + spin_version, + plugin_manager, + record_manager, + previous_badger, + }) + } + + fn should_check(&self) -> bool { + match self.previous_badger { + PreviousBadger::Fresh => true, + PreviousBadger::FromCurrent { when, .. } => has_timeout_expired(when), + } + } + + fn fire_and_forget_update() { + if let Err(e) = Self::fire_and_forget_update_impl() { + tracing::info!("Failed to launch plugins update process; checking using latest local repo anyway. Error: {e:#}"); + } + } + + fn fire_and_forget_update_impl() -> anyhow::Result<()> { + let mut update_cmd = tokio::process::Command::new(std::env::current_exe()?); + update_cmd.args(["plugins", "update"]); + update_cmd.stdout(std::process::Stdio::null()); + update_cmd.stderr(std::process::Stdio::null()); + update_cmd.spawn()?; + Ok(()) + } + + async fn check(&self) -> anyhow::Result { + let available_upgrades = self.available_upgrades().await?; + + // TO CONSIDER: skipping this check and badgering for the same upgrade in case they missed it + if self + .previous_badger + .includes_any(&available_upgrades.list()) + { + return Ok(BadgerUI::None); + } + + if !available_upgrades.is_none() { + self.record_manager + .record_badger( + &self.plugin_name, + &self.current_version, + &available_upgrades.list(), + ) + .await + }; + + Ok(available_upgrades.classify()) + } + + async fn available_upgrades(&self) -> anyhow::Result { + let store = self.plugin_manager.store(); + + let latest_version = { + let latest_lookup = crate::lookup::PluginLookup::new(&self.plugin_name, None); + let latest_manifest = latest_lookup + .get_manifest_from_repository(store.get_plugins_directory()) + .await + .ok(); + latest_manifest.and_then(|m| semver::Version::parse(m.version()).ok()) + }; + + let manifests = store.catalogue_manifests()?; + let relevant_manifests = manifests + .into_iter() + .filter(|m| m.name() == self.plugin_name); + let compatible_manifests = relevant_manifests.filter(|m| { + m.has_compatible_package() && m.is_compatible_spin_version(self.spin_version) + }); + let compatible_plugin_versions = + compatible_manifests.filter_map(|m| PluginVersion::try_from(m, &latest_version)); + let considerable_manifests = compatible_plugin_versions + .filter(|pv| !pv.is_prerelease() && pv.is_higher_than(&self.current_version)) + .collect::>(); + + let (eligible_manifests, questionable_manifests) = if self.current_version.major == 0 { + (vec![], considerable_manifests) + } else { + considerable_manifests + .into_iter() + .partition(|pv| pv.version.major == self.current_version.major) + }; + + let highest_eligible_manifest = eligible_manifests + .into_iter() + .max_by_key(|pv| pv.version.clone()); + let highest_questionable_manifest = questionable_manifests + .into_iter() + .max_by_key(|pv| pv.version.clone()); + + Ok(AvailableUpgrades { + eligible: highest_eligible_manifest, + questionable: highest_questionable_manifest, + }) + } +} + +fn has_timeout_expired(from_time: chrono::DateTime) -> bool { + let timeout = chrono::Duration::days(BADGER_TIMEOUT_DAYS); + let now = chrono::Utc::now(); + match now.checked_sub_signed(timeout) { + None => true, + Some(t) => from_time < t, + } +} + +pub struct AvailableUpgrades { + eligible: Option, + questionable: Option, +} + +impl AvailableUpgrades { + fn is_none(&self) -> bool { + self.eligible.is_none() && self.questionable.is_none() + } + + fn classify(&self) -> BadgerUI { + match (&self.eligible, &self.questionable) { + (None, None) => BadgerUI::None, + (Some(e), None) => BadgerUI::Eligible(e.clone()), + (None, Some(q)) => BadgerUI::Questionable(q.clone()), + (Some(e), Some(q)) => BadgerUI::Both { + eligible: e.clone(), + questionable: q.clone(), + }, + } + } + + fn list(&self) -> Vec<&semver::Version> { + [self.eligible.as_ref(), self.questionable.as_ref()] + .iter() + .filter_map(|pv| pv.as_ref()) + .map(|pv| &pv.version) + .collect() + } +} + +#[derive(Clone, Debug)] +pub struct PluginVersion { + version: semver::Version, + name: String, + is_latest: bool, +} + +impl PluginVersion { + fn try_from(manifest: PluginManifest, latest: &Option) -> Option { + match semver::Version::parse(manifest.version()) { + Ok(version) => { + let name = manifest.name(); + let is_latest = match latest { + None => false, + Some(latest) => &version == latest, + }; + Some(Self { + version, + name, + is_latest, + }) + } + Err(_) => None, + } + } + + fn is_prerelease(&self) -> bool { + !self.version.pre.is_empty() + } + + fn is_higher_than(&self, other: &semver::Version) -> bool { + &self.version > other + } + + pub fn upgrade_command(&self) -> String { + if self.is_latest { + format!("spin plugins upgrade {}", self.name) + } else { + format!("spin plugins upgrade {} -v {}", self.name, self.version) + } + } +} + +impl std::fmt::Display for PluginVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.version) + } +} + +pub enum BadgerUI { + // Do not badger the user. There is no available upgrade, or we have already badgered + // them recently about this plugin. + None, + // There is an available upgrade which is compatible (same non-zero major version). + Eligible(PluginVersion), + // There is an available upgrade but it may not be compatible (different major version + // or major version is zero). + Questionable(PluginVersion), + // There is an available upgrade which is compatible, but there is also an even more + // recent upgrade which may not be compatible. + Both { + eligible: PluginVersion, + questionable: PluginVersion, + }, +} diff --git a/crates/plugins/src/badger/store.rs b/crates/plugins/src/badger/store.rs new file mode 100644 index 000000000..bd9952671 --- /dev/null +++ b/crates/plugins/src/badger/store.rs @@ -0,0 +1,103 @@ +use std::path::PathBuf; + +use anyhow::anyhow; +use serde::{Deserialize, Serialize}; + +const DEFAULT_STORE_DIR: &str = "spin"; +const DEFAULT_STORE_FILE: &str = "plugins-notifications.json"; + +pub struct BadgerRecordManager { + db_path: PathBuf, +} + +#[derive(Serialize, Deserialize)] +pub struct BadgerRecord { + name: String, + badgered_from: semver::Version, + badgered_to: Vec, + when: chrono::DateTime, +} + +pub enum PreviousBadger { + Fresh, + FromCurrent { + to: Vec, + when: chrono::DateTime, + }, +} + +impl PreviousBadger { + fn includes(&self, version: &semver::Version) -> bool { + match self { + Self::Fresh => false, + Self::FromCurrent { to, .. } => to.contains(version), + } + } + + pub fn includes_any(&self, version: &[&semver::Version]) -> bool { + version.iter().any(|version| self.includes(version)) + } +} + +impl BadgerRecordManager { + pub fn default() -> anyhow::Result { + let base_dir = dirs::cache_dir() + .or_else(|| dirs::home_dir().map(|p| p.join(".spin"))) + .ok_or_else(|| anyhow!("Unable to get local data directory or home directory"))?; + let db_path = base_dir.join(DEFAULT_STORE_DIR).join(DEFAULT_STORE_FILE); + Ok(Self { db_path }) + } + + fn load(&self) -> Vec { + match std::fs::read(&self.db_path) { + Ok(v) => serde_json::from_slice(&v).unwrap_or_default(), + Err(_) => vec![], // There's no meaningful action or recovery, so swallow the error and treat the situation as fresh badger. + } + } + + fn save(&self, records: Vec) -> anyhow::Result<()> { + if let Some(dir) = self.db_path.parent() { + std::fs::create_dir_all(dir)?; + } + let json = serde_json::to_vec_pretty(&records)?; + std::fs::write(&self.db_path, json)?; + Ok(()) + } + + async fn last_badger(&self, name: &str) -> Option { + self.load().into_iter().find(|r| r.name == name) + } + + pub async fn previous_badger( + &self, + name: &str, + current_version: &semver::Version, + ) -> PreviousBadger { + match self.last_badger(name).await { + Some(b) if &b.badgered_from == current_version => PreviousBadger::FromCurrent { + to: b.badgered_to, + when: b.when, + }, + _ => PreviousBadger::Fresh, + } + } + + pub async fn record_badger(&self, name: &str, from: &semver::Version, to: &[&semver::Version]) { + let new = BadgerRecord { + name: name.to_owned(), + badgered_from: from.clone(), + badgered_to: to.iter().map(|v| ::clone(v)).collect(), + when: chrono::Utc::now(), + }; + + // There is a potential race condition here if someone runs two plugins at + // the same time. As this is unlikely, and the worst outcome is that a user + // misses a badger or gets a double badger, let's not worry about it for now. + let mut existing = self.load(); + match existing.iter().position(|r| r.name == name) { + Some(index) => existing[index] = new, + None => existing.push(new), + }; + _ = self.save(existing); + } +} diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 93eeea058..2f11333f7 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -1,3 +1,4 @@ +pub mod badger; pub mod error; mod git; pub mod lookup; diff --git a/crates/plugins/src/lookup.rs b/crates/plugins/src/lookup.rs index 08e4847aa..9c50da750 100644 --- a/crates/plugins/src/lookup.rs +++ b/crates/plugins/src/lookup.rs @@ -12,9 +12,9 @@ use url::Url; const PLUGINS_REPO_LOCAL_DIRECTORY: &str = ".spin-plugins"; // Name of directory containing the installed manifests -const PLUGINS_REPO_MANIFESTS_DIRECTORY: &str = "manifests"; +pub(crate) const PLUGINS_REPO_MANIFESTS_DIRECTORY: &str = "manifests"; -const SPIN_PLUGINS_REPO: &str = "https://github.com/fermyon/spin-plugins/"; +pub(crate) const SPIN_PLUGINS_REPO: &str = "https://github.com/fermyon/spin-plugins/"; /// Looks up plugin manifests in centralized spin plugin repository. pub struct PluginLookup { diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 87c0e62b8..eacbde206 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -156,7 +156,7 @@ fn is_version_fully_compatible(supported_on: &str, spin_version: &str) -> Result /// for Spin pre-releases, so that you don't get *every* plugin showing as incompatible when /// you run a pre-release. This is intended for listing; when executing, we use the interactive /// `warn_unsupported_version`, which provides the full nuanced feedback. -fn is_version_compatible_enough(supported_on: &str, spin_version: &str) -> Result { +pub(crate) fn is_version_compatible_enough(supported_on: &str, spin_version: &str) -> Result { if is_version_fully_compatible(supported_on, spin_version)? { Ok(true) } else { diff --git a/crates/terminal/src/lib.rs b/crates/terminal/src/lib.rs index 54dbd384f..46853401f 100644 --- a/crates/terminal/src/lib.rs +++ b/crates/terminal/src/lib.rs @@ -91,6 +91,15 @@ macro_rules! error { }}; } +#[macro_export] +macro_rules! einfo { + ($highlight:expr, $($arg:tt)*) => {{ + $crate::ceprint!($crate::colors::bold_cyan(), $highlight); + eprint!(" "); + eprintln!($($arg)*); + }}; +} + #[macro_export] macro_rules! cprint { ($color:expr, $($arg:tt)*) => { @@ -122,6 +131,10 @@ pub mod colors { new(Color::Green, true) } + pub fn bold_cyan() -> ColorSpec { + new(Color::Cyan, true) + } + fn new(color: Color, bold: bool) -> ColorSpec { let mut s = ColorSpec::new(); s.set_fg(Some(color)).set_bold(bold); diff --git a/src/commands/external.rs b/src/commands/external.rs index 5070c1688..e24e04703 100644 --- a/src/commands/external.rs +++ b/src/commands/external.rs @@ -2,11 +2,16 @@ use crate::build_info::*; use crate::commands::plugins::{update, Install}; use crate::opts::PLUGIN_OVERRIDE_COMPATIBILITY_CHECK_FLAG; use anyhow::{anyhow, Result}; -use spin_plugins::{error::Error as PluginError, manifest::warn_unsupported_version, PluginStore}; +use spin_plugins::{ + badger::BadgerChecker, error::Error as PluginError, manifest::warn_unsupported_version, + PluginStore, +}; use std::{collections::HashMap, env, process}; use tokio::process::Command; use tracing::log; +const BADGER_GRACE_PERIOD_MILLIS: u64 = 50; + fn override_flag() -> String { format!("--{}", PLUGIN_OVERRIDE_COMPATIBILITY_CHECK_FLAG) } @@ -40,14 +45,16 @@ pub async fn execute_external_subcommand( ) -> anyhow::Result<()> { let (plugin_name, args, override_compatibility_check) = parse_subcommand(cmd)?; let plugin_store = PluginStore::try_default()?; - match plugin_store.read_plugin_manifest(&plugin_name) { + let plugin_version = match plugin_store.read_plugin_manifest(&plugin_name) { Ok(manifest) => { if let Err(e) = warn_unsupported_version(&manifest, SPIN_VERSION, override_compatibility_check) { eprintln!("{e}"); + // TODO: consider running the update checked? process::exit(1); } + Some(manifest.version().to_owned()) } Err(PluginError::NotFound(e)) => { if plugin_name == "cloud" { @@ -68,6 +75,7 @@ pub async fn execute_external_subcommand( } plugin_installer.run().await?; } + None // No update badgering needed if we just updated/installed it! } else { tracing::debug!("Tried to resolve {plugin_name} to plugin, got {e}"); terminal::error!("'{plugin_name}' is not a known Spin command. See spin --help.\n"); @@ -76,15 +84,21 @@ pub async fn execute_external_subcommand( } } Err(e) => return Err(e.into()), - } + }; let mut command = Command::new(plugin_store.installed_binary_path(&plugin_name)); command.args(args); command.envs(get_env_vars_map()?); + + let badger = BadgerChecker::start(&plugin_name, plugin_version, SPIN_VERSION); + log::info!("Executing command {:?}", command); // Allow user to interact with stdio/stdout of child process let status = command.status().await?; log::info!("Exiting process with {}", status); + + report_badger_result(badger).await; + if !status.success() { match status.code() { Some(code) => process::exit(code), @@ -94,6 +108,61 @@ pub async fn execute_external_subcommand( Ok(()) } +async fn report_badger_result(badger: tokio::task::JoinHandle) { + // The badger task should be short-running, and has likely already finished by + // the time we get here (after the plugin has completed). But we don't want + // the user to have to wait if something goes amiss and it takes a long time. + // Therefore, allow it only a short grace period before killing it. + let grace_period = tokio::time::sleep(tokio::time::Duration::from_millis( + BADGER_GRACE_PERIOD_MILLIS, + )); + + let badger = tokio::select! { + _ = grace_period => { return; } + b = badger => match b { + Ok(b) => b, + Err(e) => { + tracing::info!("Badger update thread error {e:#}"); + return; + } + } + }; + + let ui = badger.check().await; + match ui { + Ok(spin_plugins::badger::BadgerUI::None) => (), + Ok(spin_plugins::badger::BadgerUI::Eligible(to)) => { + eprintln!(); + terminal::einfo!( + "This plugin can be upgraded.", + "Version {to} is available and compatible." + ); + eprintln!("To upgrade, run `{}`.", to.upgrade_command()); + } + Ok(spin_plugins::badger::BadgerUI::Questionable(to)) => { + eprintln!(); + terminal::einfo!("This plugin can be upgraded.", "Version {to} is available,"); + eprintln!("but may not be backward compatible with your current plugin."); + eprintln!("To upgrade, run `{}`.", to.upgrade_command()); + } + Ok(spin_plugins::badger::BadgerUI::Both { + eligible, + questionable, + }) => { + eprintln!(); + terminal::einfo!( + "This plugin can be upgraded.", + "Version {eligible} is available and compatible." + ); + eprintln!("Version {questionable} is also available, but may not be backward compatible with your current plugin."); + eprintln!("To upgrade, run `{}`.", eligible.upgrade_command()); + } + Err(e) => { + tracing::info!("Error running update badger: {e:#}"); + } + } +} + fn print_similar_commands(app: clap::App, plugin_name: &str) { let similar = similar_commands(app, plugin_name); match similar.len() {