spin_plugins/
manager.rs

1use crate::{
2    error::*,
3    lookup::PluginLookup,
4    manifest::{warn_unsupported_version, PluginManifest, PluginPackage},
5    store::PluginStore,
6    SPIN_INTERNAL_COMMANDS,
7};
8
9use anyhow::{anyhow, bail, Context, Result};
10use path_absolutize::Absolutize;
11use reqwest::{header::HeaderMap, Client};
12use serde::Serialize;
13use spin_common::sha256;
14use std::{
15    cmp::Ordering,
16    fs::{self, File},
17    io::{copy, Cursor},
18    path::{Path, PathBuf},
19};
20use tempfile::{tempdir, TempDir};
21use url::Url;
22
23// Url scheme prefix of a plugin that is installed from a local source
24const URL_FILE_SCHEME: &str = "file";
25
26/// Location of manifest of the plugin to be installed.
27pub enum ManifestLocation {
28    /// Plugin manifest can be copied from a local path.
29    Local(PathBuf),
30    /// Plugin manifest should be pulled from a specific address.
31    Remote(Url),
32    /// Plugin manifest lives in the centralized plugins repository
33    PluginsRepository(PluginLookup),
34}
35
36impl ManifestLocation {
37    pub(crate) fn to_install_record(&self) -> RawInstallRecord {
38        match self {
39            Self::Local(path) => {
40                // Plugin commands don't absolutise on the way in, so do it now.
41                use std::borrow::Cow;
42                let abs = path
43                    .absolutize()
44                    .unwrap_or(Cow::Borrowed(path))
45                    .to_path_buf();
46                RawInstallRecord::Local { file: abs }
47            }
48            Self::Remote(url) => RawInstallRecord::Remote {
49                url: url.to_owned(),
50            },
51            Self::PluginsRepository(_) => RawInstallRecord::PluginsRepository,
52        }
53    }
54}
55
56#[derive(Serialize)]
57#[serde(rename = "snake_case", tag = "source")]
58pub(crate) enum RawInstallRecord {
59    PluginsRepository,
60    Remote { url: Url },
61    Local { file: PathBuf },
62}
63
64/// Provides accesses to functionality to inspect and manage the installation of plugins.
65pub struct PluginManager {
66    store: PluginStore,
67}
68
69impl PluginManager {
70    /// Creates a `PluginManager` with the default install location.
71    pub fn try_default() -> anyhow::Result<Self> {
72        let store = PluginStore::try_default()?;
73        Ok(Self { store })
74    }
75
76    /// Returns the underlying store object
77    pub fn store(&self) -> &PluginStore {
78        &self.store
79    }
80
81    /// Installs the Spin plugin with the given manifest If installing a plugin from the centralized
82    /// Spin plugins repository, it fetches the latest contents of the repository and searches for
83    /// the appropriately named and versioned plugin manifest. Parses the plugin manifest to get the
84    /// appropriate source for the machine OS and architecture. Verifies the checksum of the source,
85    /// unpacks and installs it into the plugins directory.
86    /// Returns name of plugin that was successfully installed.
87    pub async fn install(
88        &self,
89        plugin_manifest: &PluginManifest,
90        plugin_package: &PluginPackage,
91        source: &ManifestLocation,
92        auth_header_value: &Option<String>,
93    ) -> Result<String> {
94        let target = plugin_package.url.to_owned();
95        let target_url = Url::parse(&target)?;
96        let temp_dir = tempdir()?;
97        let plugin_tarball_path = match target_url.scheme() {
98            URL_FILE_SCHEME => {
99                let path = target_url
100                    .to_file_path()
101                    .map_err(|_| anyhow!("Invalid file URL: {target_url:?}"))?;
102                if path.is_file() {
103                    path
104                } else {
105                    bail!(
106                        "Package path {} does not exist or is not a file",
107                        path.display()
108                    );
109                }
110            }
111            _ => {
112                download_plugin(
113                    &plugin_manifest.name(),
114                    &temp_dir,
115                    &target,
116                    auth_header_value,
117                )
118                .await?
119            }
120        };
121        verify_checksum(&plugin_tarball_path, &plugin_package.sha256)?;
122
123        self.store
124            .untar_plugin(&plugin_tarball_path, &plugin_manifest.name())
125            .with_context(|| format!("Failed to untar {}", plugin_tarball_path.display()))?;
126
127        // Save manifest to installed plugins directory
128        self.store.add_manifest(plugin_manifest)?;
129        self.write_install_record(&plugin_manifest.name(), source);
130
131        Ok(plugin_manifest.name())
132    }
133
134    /// Uninstalls a plugin with a given name, removing it and it's manifest from the local plugins
135    /// directory.
136    /// Returns true if plugin was successfully uninstalled and false if plugin did not exist.
137    pub fn uninstall(&self, plugin_name: &str) -> Result<bool> {
138        let plugin_store = self.store();
139        let manifest_file = plugin_store.installed_manifest_path(plugin_name);
140        let exists = manifest_file.exists();
141        if exists {
142            // Remove the manifest and the plugin installation directory
143            fs::remove_file(manifest_file)?;
144            fs::remove_dir_all(plugin_store.plugin_subdirectory_path(plugin_name))?;
145        }
146        Ok(exists)
147    }
148
149    /// Checks manifest to see if the plugin is compatible with the running version of Spin, does
150    /// not have a conflicting name with Spin internal commands, and is not a downgrade of a
151    /// currently installed plugin.
152    pub fn check_manifest(
153        &self,
154        plugin_manifest: &PluginManifest,
155        spin_version: &str,
156        override_compatibility_check: bool,
157        allow_downgrades: bool,
158    ) -> Result<InstallAction> {
159        // Disallow installing plugins with the same name as spin internal subcommands
160        if SPIN_INTERNAL_COMMANDS
161            .iter()
162            .any(|&s| s == plugin_manifest.name())
163        {
164            bail!(
165                "Can't install a plugin with the same name ('{}') as an internal command",
166                plugin_manifest.name()
167            );
168        }
169
170        // Disallow reinstalling identical plugins and downgrading unless permitted.
171        if let Ok(installed) = self.store.read_plugin_manifest(&plugin_manifest.name()) {
172            if &installed == plugin_manifest {
173                return Ok(InstallAction::NoAction {
174                    name: plugin_manifest.name(),
175                    version: installed.version,
176                });
177            } else if installed.compare_versions(plugin_manifest) == Some(Ordering::Greater)
178                && !allow_downgrades
179            {
180                bail!(
181                    "Newer version {} of plugin '{}' is already installed. To downgrade to version {}, run `spin plugins upgrade` with the `--downgrade` flag.",
182                    installed.version,
183                    plugin_manifest.name(),
184                    plugin_manifest.version,
185                );
186            }
187        }
188
189        warn_unsupported_version(plugin_manifest, spin_version, override_compatibility_check)?;
190
191        Ok(InstallAction::Continue)
192    }
193
194    /// Fetches a manifest from a local, remote, or repository location and returned the parsed
195    /// PluginManifest object.
196    pub async fn get_manifest(
197        &self,
198        manifest_location: &ManifestLocation,
199        skip_compatibility_check: bool,
200        spin_version: &str,
201        auth_header_value: &Option<String>,
202    ) -> PluginLookupResult<PluginManifest> {
203        let plugin_manifest = match manifest_location {
204            ManifestLocation::Remote(url) => {
205                tracing::info!("Pulling manifest for plugin from {url}");
206                let client = Client::new();
207                client
208                    .get(url.as_ref())
209                    .headers(request_headers(auth_header_value)?)
210                    .send()
211                    .await
212                    .map_err(|e| {
213                        Error::ConnectionFailed(ConnectionFailedError::new(
214                            url.as_str().to_string(),
215                            e.to_string(),
216                        ))
217                    })?
218                    .error_for_status()
219                    .map_err(|e| {
220                        Error::ConnectionFailed(ConnectionFailedError::new(
221                            url.as_str().to_string(),
222                            e.to_string(),
223                        ))
224                    })?
225                    .json::<PluginManifest>()
226                    .await
227                    .map_err(|e| {
228                        Error::InvalidManifest(InvalidManifestError::new(
229                            None,
230                            url.as_str().to_string(),
231                            e.to_string(),
232                        ))
233                    })?
234            }
235            ManifestLocation::Local(path) => {
236                tracing::info!("Pulling manifest for plugin from {}", path.display());
237                let file = File::open(path).map_err(|e| {
238                    Error::NotFound(NotFoundError::new(
239                        None,
240                        path.display().to_string(),
241                        e.to_string(),
242                    ))
243                })?;
244                serde_json::from_reader(file).map_err(|e| {
245                    Error::InvalidManifest(InvalidManifestError::new(
246                        None,
247                        path.display().to_string(),
248                        e.to_string(),
249                    ))
250                })?
251            }
252            ManifestLocation::PluginsRepository(lookup) => {
253                lookup
254                    .resolve_manifest(
255                        self.store().get_plugins_directory(),
256                        skip_compatibility_check,
257                        spin_version,
258                    )
259                    .await?
260            }
261        };
262        Ok(plugin_manifest)
263    }
264
265    pub async fn update_lock(&self) -> PluginManagerUpdateLock {
266        let lock = self.update_lock_impl().await;
267        PluginManagerUpdateLock::from(lock)
268    }
269
270    async fn update_lock_impl(&self) -> anyhow::Result<fd_lock::RwLock<tokio::fs::File>> {
271        let plugins_dir = self.store().get_plugins_directory();
272        tokio::fs::create_dir_all(plugins_dir).await?;
273        let file = tokio::fs::File::create(plugins_dir.join(".updatelock")).await?;
274        let locker = fd_lock::RwLock::new(file);
275        Ok(locker)
276    }
277
278    fn write_install_record(&self, plugin_name: &str, source: &ManifestLocation) {
279        let install_record_path = self.store.installation_record_file(plugin_name);
280
281        // A failure here shouldn't fail the install
282        let install_record = source.to_install_record();
283        if let Ok(record_text) = serde_json::to_string_pretty(&install_record) {
284            _ = std::fs::write(install_record_path, record_text);
285        }
286    }
287}
288
289// We permit the "locking failed" state rather than erroring so that we don't prevent the user
290// from doing updates just because something is amiss in the lock system. (This is basically
291// falling back to the previous, never-lock, behaviour.) Put another way, we prevent updates
292// only if we can _positively confirm_ that another update is in progress.
293pub enum PluginManagerUpdateLock {
294    Lock(fd_lock::RwLock<tokio::fs::File>),
295    Failed,
296}
297
298impl From<anyhow::Result<fd_lock::RwLock<tokio::fs::File>>> for PluginManagerUpdateLock {
299    fn from(value: anyhow::Result<fd_lock::RwLock<tokio::fs::File>>) -> Self {
300        match value {
301            Ok(lock) => Self::Lock(lock),
302            Err(_) => Self::Failed,
303        }
304    }
305}
306
307impl PluginManagerUpdateLock {
308    pub fn lock_updates(&mut self) -> PluginManagerUpdateGuard<'_> {
309        match self {
310            Self::Lock(lock) => match lock.try_write() {
311                Ok(guard) => PluginManagerUpdateGuard::Acquired(guard),
312                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
313                    PluginManagerUpdateGuard::Denied
314                }
315                _ => PluginManagerUpdateGuard::Failed,
316            },
317            Self::Failed => PluginManagerUpdateGuard::Failed,
318        }
319    }
320}
321
322#[must_use]
323pub enum PluginManagerUpdateGuard<'lock> {
324    Acquired(fd_lock::RwLockWriteGuard<'lock, tokio::fs::File>),
325    Denied,
326    Failed, // See comment on PluginManagerUpdateLock
327}
328
329impl PluginManagerUpdateGuard<'_> {
330    pub fn denied(&self) -> bool {
331        matches!(self, Self::Denied)
332    }
333}
334
335/// The action required to install a plugin to the desired version.
336pub enum InstallAction {
337    /// The installation needs to continue.
338    Continue,
339    /// No further action is required. This occurs when the plugin is already at the desired version.
340    NoAction { name: String, version: String },
341}
342
343/// Gets the appropriate package for the running OS and Arch if exists
344pub fn get_package(plugin_manifest: &PluginManifest) -> Result<&PluginPackage> {
345    use std::env::consts::{ARCH, OS};
346    plugin_manifest
347        .packages
348        .iter()
349        .find(|p| p.os.rust_name() == OS && p.arch.rust_name() == ARCH)
350        .ok_or_else(|| {
351            anyhow!("This plugin does not support this OS ({OS}) or architecture ({ARCH}).")
352        })
353}
354
355async fn download_plugin(
356    name: &str,
357    temp_dir: &TempDir,
358    target_url: &str,
359    auth_header_value: &Option<String>,
360) -> Result<PathBuf> {
361    tracing::trace!("Trying to get tar file for plugin '{name}' from {target_url}");
362    let client = Client::new();
363    let plugin_bin = client
364        .get(target_url)
365        .headers(request_headers(auth_header_value)?)
366        .send()
367        .await?;
368    if !plugin_bin.status().is_success() {
369        match plugin_bin.status() {
370            reqwest::StatusCode::NOT_FOUND => bail!("The download URL specified in the plugin manifest was not found ({target_url} returned HTTP error 404). Please contact the plugin author."),
371            _ => bail!("HTTP error {} when downloading plugin from {target_url}", plugin_bin.status()),
372        }
373    }
374
375    let mut content = Cursor::new(plugin_bin.bytes().await?);
376    let dir = temp_dir.path();
377    let mut plugin_file = dir.join(name);
378    plugin_file.set_extension("tar.gz");
379    let mut temp_file = File::create(&plugin_file)?;
380    copy(&mut content, &mut temp_file)?;
381    Ok(plugin_file)
382}
383
384fn verify_checksum(plugin_file: &Path, expected_sha256: &str) -> Result<()> {
385    let actual_sha256 = sha256::hex_digest_from_file(plugin_file)
386        .with_context(|| format!("Cannot get digest for {}", plugin_file.display()))?;
387    if actual_sha256 == expected_sha256 {
388        tracing::info!("Package checksum verified successfully");
389        Ok(())
390    } else {
391        Err(anyhow!("Checksum did not match, aborting installation."))
392    }
393}
394
395/// Get the request headers for a call to the plugin API
396///
397/// If set, this will include the user provided authorization header.
398fn request_headers(auth_header_value: &Option<String>) -> Result<HeaderMap> {
399    let mut headers = HeaderMap::new();
400    if let Some(auth_value) = auth_header_value {
401        headers.insert(reqwest::header::AUTHORIZATION, auth_value.parse()?);
402    }
403    Ok(headers)
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[tokio::test]
411    async fn good_error_when_tarball_404s() -> anyhow::Result<()> {
412        let temp_dir = tempdir()?;
413        let store = PluginStore::new(temp_dir.path());
414        let manager = PluginManager { store };
415
416        let bad_manifest: PluginManifest = serde_json::from_str(include_str!(
417            "../tests/nonexistent-url/nonexistent-url.json"
418        ))?;
419
420        let install_result = manager
421            .install(
422                &bad_manifest,
423                &bad_manifest.packages[0],
424                &ManifestLocation::Local(PathBuf::from(
425                    "../tests/nonexistent-url/nonexistent-url.json",
426                )),
427                &None,
428            )
429            .await;
430
431        let err = format!("{:#}", install_result.unwrap_err());
432        assert!(
433            err.contains("not found"),
434            "Expected error to contain 'not found' but was '{err}'"
435        );
436
437        Ok(())
438    }
439}