Skip to main content

spin_plugins/
manager.rs

1use crate::{
2    SPIN_INTERNAL_COMMANDS,
3    error::*,
4    lookup::PluginRef,
5    manifest::{PluginManifest, PluginPackage, warn_unsupported_version},
6    store::PluginStore,
7};
8
9use anyhow::{Context, Result, anyhow, bail};
10use path_absolutize::Absolutize;
11use reqwest::{Client, header::HeaderMap};
12use serde::Serialize;
13use spin_common::sha256;
14use std::{
15    cmp::Ordering,
16    fs::{self, File},
17    io::{Cursor, copy},
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(PluginRef),
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/// The entry point for plugin functionality. Use this to list, install, and remove
65/// plugins, and to locate plugin binaries for execution.
66///
67/// PluginManager also provides access to the catalogue of available manifests via
68/// the `catalogue()` function. It also provides for synchronised catalogue updates.
69pub struct PluginManager {
70    store: PluginStore,
71}
72
73impl PluginManager {
74    /// Creates a `PluginManager` with the default install location.
75    pub fn try_default() -> anyhow::Result<Self> {
76        let store = PluginStore::try_default()?;
77        Ok(Self { store })
78    }
79
80    /// Installs the Spin plugin with the given manifest If installing a plugin from the centralized
81    /// Spin plugins repository, it fetches the latest contents of the repository and searches for
82    /// the appropriately named and versioned plugin manifest. Parses the plugin manifest to get the
83    /// appropriate source for the machine OS and architecture. Verifies the checksum of the source,
84    /// unpacks and installs it into the plugins directory.
85    /// Returns name of plugin that was successfully installed.
86    pub async fn install(
87        &self,
88        plugin_manifest: &PluginManifest,
89        plugin_package: &PluginPackage,
90        source: &ManifestLocation,
91        auth_header_value: &Option<String>,
92    ) -> Result<String> {
93        let target = plugin_package.url.to_owned();
94        let target_url = Url::parse(&target)?;
95        let temp_dir = tempdir()?;
96        let plugin_tarball_path = match target_url.scheme() {
97            URL_FILE_SCHEME => {
98                let path = target_url
99                    .to_file_path()
100                    .map_err(|_| anyhow!("Invalid file URL: {target_url:?}"))?;
101                if path.is_file() {
102                    path
103                } else {
104                    bail!(
105                        "Package path {} does not exist or is not a file",
106                        path.display()
107                    );
108                }
109            }
110            _ => {
111                download_plugin(
112                    &plugin_manifest.name(),
113                    &temp_dir,
114                    &target,
115                    auth_header_value,
116                )
117                .await?
118            }
119        };
120        verify_checksum(&plugin_tarball_path, &plugin_package.sha256)?;
121
122        self.store
123            .untar_plugin(&plugin_tarball_path, &plugin_manifest.name())
124            .with_context(|| format!("Failed to untar {}", plugin_tarball_path.display()))?;
125
126        // Save manifest to installed plugins directory
127        self.store.add_manifest(plugin_manifest)?;
128        self.write_install_record(&plugin_manifest.name(), source);
129
130        Ok(plugin_manifest.name())
131    }
132
133    /// Installs the latest (default) version of the given plugin from the
134    /// catalogue, checking for compatbility against the given Spin version
135    /// (unfortunately we can't infer this because this is a crate not a command).
136    ///
137    /// This is roughly equivalent to `spin plugins install <name>` with no options.
138    pub async fn install_latest(&self, name: &str, spin_version: &str) -> anyhow::Result<String> {
139        let manifest_location = ManifestLocation::PluginsRepository(PluginRef {
140            name: name.to_string(),
141            version: None,
142        });
143        let plugin_manifest = self
144            .get_manifest(&manifest_location, false, spin_version, &None)
145            .await?;
146        let plugin_package = plugin_manifest
147            .get_package()
148            .context("Plugin does not contain a compatible package")?;
149        self.install(&plugin_manifest, plugin_package, &manifest_location, &None)
150            .await
151    }
152
153    /// Uninstalls a plugin with a given name, removing it and it's manifest from the local plugins
154    /// directory.
155    /// Returns true if plugin was successfully uninstalled and false if plugin did not exist.
156    pub fn uninstall(&self, plugin_name: &str) -> Result<bool> {
157        let plugin_store = &self.store;
158        let manifest_file = plugin_store.installed_manifest_path(plugin_name);
159        let exists = manifest_file.exists();
160        if exists {
161            // Remove the manifest and the plugin installation directory
162            fs::remove_file(manifest_file)?;
163            fs::remove_dir_all(plugin_store.plugin_subdirectory_path(plugin_name))?;
164        }
165        Ok(exists)
166    }
167
168    /// Checks manifest to see if the plugin is compatible with the running version of Spin, does
169    /// not have a conflicting name with Spin internal commands, and is not a downgrade of a
170    /// currently installed plugin.
171    pub fn check_manifest(
172        &self,
173        plugin_manifest: &PluginManifest,
174        spin_version: &str,
175        override_compatibility_check: bool,
176        allow_downgrades: bool,
177    ) -> Result<InstallAction> {
178        // Disallow installing plugins with the same name as spin internal subcommands
179        if SPIN_INTERNAL_COMMANDS
180            .iter()
181            .any(|&s| s == plugin_manifest.name())
182        {
183            bail!(
184                "Can't install a plugin with the same name ('{}') as an internal command",
185                plugin_manifest.name()
186            );
187        }
188
189        // Disallow reinstalling identical plugins and downgrading unless permitted.
190        if let Ok(installed) = self.get_installed_manifest(&plugin_manifest.name()) {
191            if &installed == plugin_manifest {
192                return Ok(InstallAction::NoAction {
193                    name: plugin_manifest.name(),
194                    version: installed.version,
195                });
196            } else if installed.compare_versions(plugin_manifest) == Some(Ordering::Greater)
197                && !allow_downgrades
198            {
199                bail!(
200                    "Newer version {} of plugin '{}' is already installed. To downgrade to version {}, run `spin plugins upgrade` with the `--downgrade` flag.",
201                    installed.version,
202                    plugin_manifest.name(),
203                    plugin_manifest.version,
204                );
205            }
206        }
207
208        warn_unsupported_version(plugin_manifest, spin_version, override_compatibility_check)?;
209
210        Ok(InstallAction::Continue)
211    }
212
213    /// Fetches a manifest from a local, remote, or repository location and returned the parsed
214    /// PluginManifest object.
215    pub async fn get_manifest(
216        &self,
217        manifest_location: &ManifestLocation,
218        skip_compatibility_check: bool,
219        spin_version: &str,
220        auth_header_value: &Option<String>,
221    ) -> PluginLookupResult<PluginManifest> {
222        let plugin_manifest = match manifest_location {
223            ManifestLocation::Remote(url) => {
224                tracing::info!("Pulling manifest for plugin from {url}");
225                let client = Client::new();
226                client
227                    .get(url.as_ref())
228                    .headers(request_headers(auth_header_value)?)
229                    .send()
230                    .await
231                    .map_err(|e| {
232                        Error::ConnectionFailed(ConnectionFailedError::new(
233                            url.as_str().to_string(),
234                            e.to_string(),
235                        ))
236                    })?
237                    .error_for_status()
238                    .map_err(|e| {
239                        Error::ConnectionFailed(ConnectionFailedError::new(
240                            url.as_str().to_string(),
241                            e.to_string(),
242                        ))
243                    })?
244                    .json::<PluginManifest>()
245                    .await
246                    .map_err(|e| {
247                        Error::InvalidManifest(InvalidManifestError::new(
248                            None,
249                            url.as_str().to_string(),
250                            e.to_string(),
251                        ))
252                    })?
253            }
254            ManifestLocation::Local(path) => {
255                tracing::info!("Pulling manifest for plugin from {}", path.display());
256                let file = File::open(path).map_err(|e| {
257                    Error::NotFound(NotFoundError::new(
258                        None,
259                        path.display().to_string(),
260                        e.to_string(),
261                    ))
262                })?;
263                serde_json::from_reader(file).map_err(|e| {
264                    Error::InvalidManifest(InvalidManifestError::new(
265                        None,
266                        path.display().to_string(),
267                        e.to_string(),
268                    ))
269                })?
270            }
271            ManifestLocation::PluginsRepository(lookup) => {
272                lookup
273                    .resolve_manifest(&self.catalogue(), skip_compatibility_check, spin_version)
274                    .await?
275            }
276        };
277        Ok(plugin_manifest)
278    }
279
280    /// Returns the PluginManifest for an installed plugin with a given name.
281    /// Looks up and parses the JSON plugin manifest file into object form.
282    pub fn get_installed_manifest(&self, plugin_name: &str) -> PluginLookupResult<PluginManifest> {
283        let manifest_path = self.store.installed_manifest_path(plugin_name);
284        tracing::info!("Reading plugin manifest from {}", manifest_path.display());
285        let manifest_file = File::open(manifest_path.clone()).map_err(|e| {
286            Error::NotFound(NotFoundError::new(
287                Some(plugin_name.to_string()),
288                manifest_path.display().to_string(),
289                e.to_string(),
290            ))
291        })?;
292        let manifest = serde_json::from_reader(manifest_file).map_err(|e| {
293            Error::InvalidManifest(InvalidManifestError::new(
294                Some(plugin_name.to_string()),
295                manifest_path.display().to_string(),
296                e.to_string(),
297            ))
298        })?;
299        Ok(manifest)
300    }
301
302    pub fn is_empty(&self) -> bool {
303        let manifests_dir = self.store.installed_manifests_directory();
304        if !manifests_dir.exists() {
305            return true;
306        }
307        let Ok(mut rd) = manifests_dir.read_dir() else {
308            return true;
309        };
310        rd.next().is_none()
311    }
312
313    pub fn installed_plugins(&self) -> anyhow::Result<Vec<PluginManifest>> {
314        let manifests_dir = self.store.installed_manifests_directory();
315        let manifest_paths = crate::util::json_files_in(&manifests_dir);
316        let manifests = manifest_paths
317            .iter()
318            .filter_map(|path| crate::util::try_read_manifest_from(path))
319            .collect();
320        Ok(manifests)
321    }
322
323    pub async fn installed_plugins_latest_versions(
324        &self,
325        skip_compatibility_check: bool,
326        spin_version: &str,
327        auth_header_value: &Option<String>,
328    ) -> anyhow::Result<Vec<(PluginManifest, ManifestLocation)>> {
329        let mut plugins = vec![];
330
331        let manifests_dir = self.store.installed_manifests_directory();
332
333        for plugin in std::fs::read_dir(manifests_dir)? {
334            let path = plugin?.path();
335            let name = path
336                .file_stem()
337                .ok_or_else(|| anyhow!("No stem for path {}", path.display()))?
338                .to_str()
339                .ok_or_else(|| anyhow!("Cannot convert path {} stem to str", path.display()))?
340                .to_string();
341            let manifest_location =
342                ManifestLocation::PluginsRepository(PluginRef::new(&name, None));
343            let manifest = match self
344                .get_manifest(
345                    &manifest_location,
346                    skip_compatibility_check,
347                    spin_version,
348                    auth_header_value,
349                )
350                .await
351            {
352                Err(Error::NotFound(e)) => {
353                    tracing::info!("Could not upgrade plugin '{name}': {e:?}");
354                    continue;
355                }
356                Err(e) => return Err(e.into()),
357                Ok(m) => m,
358            };
359
360            plugins.push((manifest, manifest_location));
361        }
362
363        Ok(plugins)
364    }
365
366    pub fn is_installed(&self, plugin_name: &str) -> bool {
367        self.installed_plugins()
368            .unwrap_or_default()
369            .iter()
370            .any(|m| m.name() == plugin_name)
371    }
372
373    pub fn is_installed_exact(&self, manifest: &PluginManifest) -> bool {
374        match self.get_installed_manifest(&manifest.name()) {
375            Ok(m) => m.eq(manifest),
376            Err(_) => false,
377        }
378    }
379
380    pub async fn update(&self) -> Result<()> {
381        let mut locker = self.update_lock().await;
382        let guard = locker.lock_updates();
383        if guard.denied() {
384            anyhow::bail!("Another plugin update operation is already in progress");
385        }
386
387        let url = crate::catalogue::plugins_repo_url()?;
388        self.catalogue().fetch_from_remote(&url).await?;
389        Ok(())
390    }
391
392    async fn update_lock(&self) -> PluginManagerUpdateLock {
393        let lock = self.update_lock_impl().await;
394        PluginManagerUpdateLock::from(lock)
395    }
396
397    async fn update_lock_impl(&self) -> anyhow::Result<fd_lock::RwLock<tokio::fs::File>> {
398        let plugins_dir = self.store.get_plugins_directory();
399        tokio::fs::create_dir_all(plugins_dir).await?;
400        let file = tokio::fs::File::create(plugins_dir.join(".updatelock")).await?;
401        let locker = fd_lock::RwLock::new(file);
402        Ok(locker)
403    }
404
405    pub fn catalogue(&self) -> crate::Catalogue {
406        self.store.catalogue()
407    }
408
409    pub fn installed_binary_path(&self, plugin_name: &str) -> PathBuf {
410        self.store.installed_binary_path(plugin_name)
411    }
412
413    fn write_install_record(&self, plugin_name: &str, source: &ManifestLocation) {
414        let install_record_path = self.store.installation_record_file(plugin_name);
415
416        // A failure here shouldn't fail the install
417        let install_record = source.to_install_record();
418        if let Ok(record_text) = serde_json::to_string_pretty(&install_record) {
419            _ = std::fs::write(install_record_path, record_text);
420        }
421    }
422}
423
424// We permit the "locking failed" state rather than erroring so that we don't prevent the user
425// from doing updates just because something is amiss in the lock system. (This is basically
426// falling back to the previous, never-lock, behaviour.) Put another way, we prevent updates
427// only if we can _positively confirm_ that another update is in progress.
428pub enum PluginManagerUpdateLock {
429    Lock(fd_lock::RwLock<tokio::fs::File>),
430    Failed,
431}
432
433impl From<anyhow::Result<fd_lock::RwLock<tokio::fs::File>>> for PluginManagerUpdateLock {
434    fn from(value: anyhow::Result<fd_lock::RwLock<tokio::fs::File>>) -> Self {
435        match value {
436            Ok(lock) => Self::Lock(lock),
437            Err(_) => Self::Failed,
438        }
439    }
440}
441
442impl PluginManagerUpdateLock {
443    pub fn lock_updates(&mut self) -> PluginManagerUpdateGuard<'_> {
444        match self {
445            Self::Lock(lock) => match lock.try_write() {
446                Ok(guard) => PluginManagerUpdateGuard::Acquired(guard),
447                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
448                    PluginManagerUpdateGuard::Denied
449                }
450                _ => PluginManagerUpdateGuard::Failed,
451            },
452            Self::Failed => PluginManagerUpdateGuard::Failed,
453        }
454    }
455}
456
457#[must_use]
458pub enum PluginManagerUpdateGuard<'lock> {
459    Acquired(fd_lock::RwLockWriteGuard<'lock, tokio::fs::File>),
460    Denied,
461    Failed, // See comment on PluginManagerUpdateLock
462}
463
464impl PluginManagerUpdateGuard<'_> {
465    pub fn denied(&self) -> bool {
466        matches!(self, Self::Denied)
467    }
468}
469
470/// The action required to install a plugin to the desired version.
471pub enum InstallAction {
472    /// The installation needs to continue.
473    Continue,
474    /// No further action is required. This occurs when the plugin is already at the desired version.
475    NoAction { name: String, version: String },
476}
477
478async fn download_plugin(
479    name: &str,
480    temp_dir: &TempDir,
481    target_url: &str,
482    auth_header_value: &Option<String>,
483) -> Result<PathBuf> {
484    tracing::trace!("Trying to get tar file for plugin '{name}' from {target_url}");
485    let client = Client::new();
486    let plugin_bin = client
487        .get(target_url)
488        .headers(request_headers(auth_header_value)?)
489        .send()
490        .await?;
491    if !plugin_bin.status().is_success() {
492        match plugin_bin.status() {
493            reqwest::StatusCode::NOT_FOUND => bail!(
494                "The download URL specified in the plugin manifest was not found ({target_url} returned HTTP error 404). Please contact the plugin author."
495            ),
496            _ => bail!(
497                "HTTP error {} when downloading plugin from {target_url}",
498                plugin_bin.status()
499            ),
500        }
501    }
502
503    let mut content = Cursor::new(plugin_bin.bytes().await?);
504    let dir = temp_dir.path();
505    let mut plugin_file = dir.join(name);
506    plugin_file.set_extension("tar.gz");
507    let mut temp_file = File::create(&plugin_file)?;
508    copy(&mut content, &mut temp_file)?;
509    Ok(plugin_file)
510}
511
512fn verify_checksum(plugin_file: &Path, expected_sha256: &str) -> Result<()> {
513    let actual_sha256 = sha256::hex_digest_from_file(plugin_file)
514        .with_context(|| format!("Cannot get digest for {}", plugin_file.display()))?;
515    if actual_sha256 == expected_sha256 {
516        tracing::info!("Package checksum verified successfully");
517        Ok(())
518    } else {
519        Err(anyhow!("Checksum did not match, aborting installation."))
520    }
521}
522
523/// Get the request headers for a call to the plugin API
524///
525/// If set, this will include the user provided authorization header.
526fn request_headers(auth_header_value: &Option<String>) -> Result<HeaderMap> {
527    let mut headers = HeaderMap::new();
528    if let Some(auth_value) = auth_header_value {
529        headers.insert(reqwest::header::AUTHORIZATION, auth_value.parse()?);
530    }
531    Ok(headers)
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[tokio::test]
539    async fn good_error_when_tarball_404s() -> anyhow::Result<()> {
540        let temp_dir = tempdir()?;
541        let store = PluginStore::new(temp_dir.path());
542        let manager = PluginManager { store };
543
544        let bad_manifest: PluginManifest = serde_json::from_str(include_str!(
545            "../tests/nonexistent-url/nonexistent-url.json"
546        ))?;
547
548        let install_result = manager
549            .install(
550                &bad_manifest,
551                &bad_manifest.packages[0],
552                &ManifestLocation::Local(PathBuf::from(
553                    "../tests/nonexistent-url/nonexistent-url.json",
554                )),
555                &None,
556            )
557            .await;
558
559        let err = format!("{:#}", install_result.unwrap_err());
560        assert!(
561            err.contains("not found"),
562            "Expected error to contain 'not found' but was '{err}'"
563        );
564
565        Ok(())
566    }
567}