spin_plugins/
lookup.rs

1use crate::{error::*, git::GitSource, manifest::PluginManifest, store::manifest_file_name};
2use semver::Version;
3use std::{
4    fs::File,
5    path::{Path, PathBuf},
6};
7use url::Url;
8
9// Name of directory that contains the cloned centralized Spin plugins
10// repository
11const PLUGINS_REPO_LOCAL_DIRECTORY: &str = ".spin-plugins";
12
13// Name of directory containing the installed manifests
14pub(crate) const PLUGINS_REPO_MANIFESTS_DIRECTORY: &str = "manifests";
15
16pub(crate) const SPIN_PLUGINS_REPO: &str = "https://github.com/spinframework/spin-plugins/";
17
18/// Looks up plugin manifests in centralized spin plugin repository.
19pub struct PluginLookup {
20    pub name: String,
21    pub version: Option<Version>,
22}
23
24impl PluginLookup {
25    pub fn new(name: &str, version: Option<Version>) -> Self {
26        Self {
27            name: name.to_lowercase(),
28            version,
29        }
30    }
31
32    pub async fn resolve_manifest(
33        &self,
34        plugins_dir: &Path,
35        skip_compatibility_check: bool,
36        spin_version: &str,
37    ) -> PluginLookupResult<PluginManifest> {
38        let exact = self.resolve_manifest_exact(plugins_dir).await?;
39        if skip_compatibility_check
40            || self.version.is_some()
41            || exact.is_compatible_spin_version(spin_version)
42        {
43            return Ok(exact);
44        }
45
46        let store = crate::store::PluginStore::new(plugins_dir.to_owned());
47
48        // TODO: This is very similar to some logic in the badger module - look for consolidation opportunities.
49        let manifests = store.catalogue_manifests()?;
50        let relevant_manifests = manifests.into_iter().filter(|m| m.name() == self.name);
51        let compatible_manifests = relevant_manifests
52            .filter(|m| m.has_compatible_package() && m.is_compatible_spin_version(spin_version));
53        let highest_compatible_manifest =
54            compatible_manifests.max_by_key(|m| m.try_version().unwrap_or_else(|_| null_version()));
55
56        Ok(highest_compatible_manifest.unwrap_or(exact))
57    }
58
59    pub async fn resolve_manifest_exact(
60        &self,
61        plugins_dir: &Path,
62    ) -> PluginLookupResult<PluginManifest> {
63        let url = plugins_repo_url()?;
64        tracing::info!("Pulling manifest for plugin {} from {url}", self.name);
65        fetch_plugins_repo(&url, plugins_dir, false)
66            .await
67            .map_err(|e| {
68                Error::ConnectionFailed(ConnectionFailedError::new(url.to_string(), e.to_string()))
69            })?;
70
71        self.resolve_manifest_exact_from_good_repo(plugins_dir)
72    }
73
74    // This is split from resolve_manifest_exact because it may recurse (once) and that makes
75    // Rust async sad. So we move the potential recursion to a sync helper.
76    #[allow(clippy::let_and_return)]
77    pub fn resolve_manifest_exact_from_good_repo(
78        &self,
79        plugins_dir: &Path,
80    ) -> PluginLookupResult<PluginManifest> {
81        let expected_path = spin_plugins_repo_manifest_path(&self.name, &self.version, plugins_dir);
82
83        let not_found = |e: std::io::Error| {
84            Err(Error::NotFound(NotFoundError::new(
85                Some(self.name.clone()),
86                expected_path.display().to_string(),
87                e.to_string(),
88            )))
89        };
90
91        let manifest = match File::open(&expected_path) {
92            Ok(file) => serde_json::from_reader(file).map_err(|e| {
93                Error::InvalidManifest(InvalidManifestError::new(
94                    Some(self.name.clone()),
95                    expected_path.display().to_string(),
96                    e.to_string(),
97                ))
98            }),
99            Err(e) if e.kind() == std::io::ErrorKind::NotFound && self.version.is_some() => {
100                // If a user has asked for a version by number, and the path doesn't exist,
101                // it _might_ be because it's the latest version. This checks for that case.
102                let latest = Self::new(&self.name, None);
103                match latest.resolve_manifest_exact_from_good_repo(plugins_dir) {
104                    Ok(manifest) if manifest.try_version().ok() == self.version => Ok(manifest),
105                    _ => not_found(e),
106                }
107            }
108            Err(e) => not_found(e),
109        };
110
111        manifest
112    }
113}
114
115pub fn plugins_repo_url() -> Result<Url, url::ParseError> {
116    Url::parse(SPIN_PLUGINS_REPO)
117}
118
119#[cfg(not(test))]
120fn accept_as_repo(git_root: &Path) -> bool {
121    git_root.join(".git").exists()
122}
123
124#[cfg(test)]
125fn accept_as_repo(git_root: &Path) -> bool {
126    git_root.join(".git").exists() || git_root.join("_spin_test_dot_git").exists()
127}
128
129pub async fn fetch_plugins_repo(
130    repo_url: &Url,
131    plugins_dir: &Path,
132    update: bool,
133) -> anyhow::Result<()> {
134    let git_root = plugin_manifests_repo_path(plugins_dir);
135    let git_source = GitSource::new(repo_url, None, &git_root);
136    if accept_as_repo(&git_root) {
137        if update {
138            git_source.pull().await?;
139        }
140    } else {
141        git_source.clone_repo().await?;
142    }
143    Ok(())
144}
145
146fn plugin_manifests_repo_path(plugins_dir: &Path) -> PathBuf {
147    plugins_dir.join(PLUGINS_REPO_LOCAL_DIRECTORY)
148}
149
150// Given a name and option version, outputs expected file name for the plugin.
151fn manifest_file_name_version(plugin_name: &str, version: &Option<semver::Version>) -> String {
152    match version {
153        Some(v) => format!("{}@{}.json", plugin_name, v),
154        None => manifest_file_name(plugin_name),
155    }
156}
157
158/// Get expected path to the manifest of a plugin with a given name
159/// and version within the spin-plugins repository
160fn spin_plugins_repo_manifest_path(
161    plugin_name: &str,
162    plugin_version: &Option<Version>,
163    plugins_dir: &Path,
164) -> PathBuf {
165    spin_plugins_repo_manifest_dir(plugins_dir)
166        .join(plugin_name)
167        .join(manifest_file_name_version(plugin_name, plugin_version))
168}
169
170pub fn spin_plugins_repo_manifest_dir(plugins_dir: &Path) -> PathBuf {
171    plugins_dir
172        .join(PLUGINS_REPO_LOCAL_DIRECTORY)
173        .join(PLUGINS_REPO_MANIFESTS_DIRECTORY)
174}
175
176fn null_version() -> semver::Version {
177    semver::Version::new(0, 0, 0)
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    const TEST_NAME: &str = "some-spin-ver-some-not";
185    const TESTS_STORE_DIR: &str = "tests";
186
187    fn tests_store_dir() -> PathBuf {
188        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(TESTS_STORE_DIR)
189    }
190
191    #[tokio::test]
192    async fn if_no_version_given_and_latest_is_compatible_then_latest() -> PluginLookupResult<()> {
193        let lookup = PluginLookup::new(TEST_NAME, None);
194        let resolved = lookup
195            .resolve_manifest(&tests_store_dir(), false, "99.0.0")
196            .await?;
197        assert_eq!("99.0.1", resolved.version);
198        Ok(())
199    }
200
201    #[tokio::test]
202    async fn if_no_version_given_and_latest_is_not_compatible_then_highest_compatible(
203    ) -> PluginLookupResult<()> {
204        // NOTE: The setup assumes you are NOT running Windows on aarch64, so as to check 98.1.0 is not
205        // offered. If that assumption fails then this test will fail with actual version being 98.1.0.
206        // (We use this combination because the OS and architecture enums don't allow for fake operating systems!)
207        let lookup = PluginLookup::new(TEST_NAME, None);
208        let resolved = lookup
209            .resolve_manifest(&tests_store_dir(), false, "98.0.0")
210            .await?;
211        assert_eq!("98.0.0", resolved.version);
212        Ok(())
213    }
214
215    #[tokio::test]
216    async fn if_version_given_it_gets_used_regardless() -> PluginLookupResult<()> {
217        let lookup = PluginLookup::new(TEST_NAME, Some(semver::Version::parse("99.0.0").unwrap()));
218        let resolved = lookup
219            .resolve_manifest(&tests_store_dir(), false, "98.0.0")
220            .await?;
221        assert_eq!("99.0.0", resolved.version);
222        Ok(())
223    }
224
225    #[tokio::test]
226    async fn if_latest_version_given_it_gets_used_regardless() -> PluginLookupResult<()> {
227        let lookup = PluginLookup::new(TEST_NAME, Some(semver::Version::parse("99.0.1").unwrap()));
228        let resolved = lookup
229            .resolve_manifest(&tests_store_dir(), false, "98.0.0")
230            .await?;
231        assert_eq!("99.0.1", resolved.version);
232        Ok(())
233    }
234
235    #[tokio::test]
236    async fn if_no_version_given_but_skip_compat_then_highest() -> PluginLookupResult<()> {
237        let lookup = PluginLookup::new(TEST_NAME, None);
238        let resolved = lookup
239            .resolve_manifest(&tests_store_dir(), true, "98.0.0")
240            .await?;
241        assert_eq!("99.0.1", resolved.version);
242        Ok(())
243    }
244
245    #[tokio::test]
246    async fn if_non_existent_version_given_then_error() -> PluginLookupResult<()> {
247        let lookup = PluginLookup::new(TEST_NAME, Some(semver::Version::parse("177.7.7").unwrap()));
248        lookup
249            .resolve_manifest(&tests_store_dir(), true, "99.0.0")
250            .await
251            .expect_err("Should have errored because plugin v177.7.7 does not exist");
252        Ok(())
253    }
254}