Skip to main content

spin_plugins/
lookup.rs

1use crate::{Catalogue, catalogue::plugins_repo_url, error::*, manifest::PluginManifest};
2use semver::Version;
3use std::fs::File;
4
5/// Looks up plugin manifests in centralized spin plugin repository.
6pub struct PluginRef {
7    pub name: String,
8    pub version: Option<Version>,
9}
10
11impl PluginRef {
12    pub fn new(name: &str, version: Option<Version>) -> Self {
13        Self {
14            name: name.to_lowercase(),
15            version,
16        }
17    }
18
19    /// This looks up this reference in the current snapshot, but if the reference
20    /// is missing or incompatible with the given version of Spin and the current OS
21    /// and processor environment, then it tries to find a fallback version
22    /// in the snapshot that *will* work. This is the "eager to please" resolver.
23    pub(crate) async fn resolve_manifest(
24        &self,
25        catalogue: &Catalogue,
26        skip_compatibility_check: bool,
27        spin_version: &str,
28    ) -> PluginLookupResult<PluginManifest> {
29        let exact = self.resolve_manifest_exact(catalogue).await?;
30        if skip_compatibility_check
31            || self.version.is_some()
32            || exact.is_compatible_spin_version(spin_version)
33        {
34            return Ok(exact);
35        }
36
37        // TODO: This is very similar to some logic in the badger module - look for consolidation opportunities.
38        let manifests = catalogue.manifests()?;
39        let relevant_manifests = manifests.into_iter().filter(|m| m.name() == self.name);
40        let compatible_manifests = relevant_manifests
41            .filter(|m| m.has_compatible_package() && m.is_compatible_spin_version(spin_version));
42        let highest_compatible_manifest =
43            compatible_manifests.max_by_key(|m| m.try_version().unwrap_or_else(|_| null_version()));
44
45        Ok(highest_compatible_manifest.unwrap_or(exact))
46    }
47
48    /// This looks up this **exact** reference in the current snapshot. The snapshot
49    /// will not be refreshed, but it may be initialised if it does not yet exist.
50    /// Compatibility is not considered; no alternative versions are considered.
51    pub(crate) async fn resolve_manifest_exact(
52        &self,
53        catalogue: &Catalogue,
54    ) -> PluginLookupResult<PluginManifest> {
55        let url = plugins_repo_url()?;
56        tracing::info!("Pulling manifest for plugin {} from {url}", self.name);
57        catalogue.ensure_inited(&url).await.map_err(|e| {
58            Error::ConnectionFailed(ConnectionFailedError::new(url.to_string(), e.to_string()))
59        })?;
60
61        self.resolve_manifest_exact_from_good_repo(catalogue)
62    }
63
64    // This is split from resolve_manifest_exact because it may recurse (once) and that makes
65    // Rust async sad. So we move the potential recursion to a sync helper.
66    #[allow(clippy::let_and_return)]
67    fn resolve_manifest_exact_from_good_repo(
68        &self,
69        catalogue: &Catalogue,
70    ) -> PluginLookupResult<PluginManifest> {
71        let expected_path = catalogue.manifest_path(&self.name, &self.version);
72
73        let not_found = |e: std::io::Error| {
74            Err(Error::NotFound(NotFoundError::new(
75                Some(self.name.clone()),
76                expected_path.display().to_string(),
77                e.to_string(),
78            )))
79        };
80
81        let manifest = match File::open(&expected_path) {
82            Ok(file) => serde_json::from_reader(file).map_err(|e| {
83                Error::InvalidManifest(InvalidManifestError::new(
84                    Some(self.name.clone()),
85                    expected_path.display().to_string(),
86                    e.to_string(),
87                ))
88            }),
89            Err(e) if e.kind() == std::io::ErrorKind::NotFound && self.version.is_some() => {
90                // If a user has asked for a version by number, and the path doesn't exist,
91                // it _might_ be because it's the latest version. This checks for that case.
92                let latest = Self::new(&self.name, None);
93                match latest.resolve_manifest_exact_from_good_repo(catalogue) {
94                    Ok(manifest) if manifest.try_version().ok() == self.version => Ok(manifest),
95                    _ => not_found(e),
96                }
97            }
98            Err(e) => not_found(e),
99        };
100
101        manifest
102    }
103}
104
105fn null_version() -> semver::Version {
106    semver::Version::new(0, 0, 0)
107}
108
109#[cfg(test)]
110mod tests {
111    use std::path::PathBuf;
112
113    use super::*;
114    use crate::store::PluginStore;
115
116    const TEST_NAME: &str = "some-spin-ver-some-not";
117    const TESTS_STORE_DIR: &str = "tests";
118
119    fn tests_store_dir() -> PathBuf {
120        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(TESTS_STORE_DIR)
121    }
122
123    fn tests_store() -> Catalogue {
124        PluginStore::new(tests_store_dir()).catalogue()
125    }
126
127    #[tokio::test]
128    async fn if_no_version_given_and_latest_is_compatible_then_latest() -> PluginLookupResult<()> {
129        let lookup = PluginRef::new(TEST_NAME, None);
130        let resolved = lookup
131            .resolve_manifest(&tests_store(), false, "99.0.0")
132            .await?;
133        assert_eq!("99.0.1", resolved.version);
134        Ok(())
135    }
136
137    #[tokio::test]
138    async fn if_no_version_given_and_latest_is_not_compatible_then_highest_compatible()
139    -> PluginLookupResult<()> {
140        // NOTE: The setup assumes you are NOT running Windows on aarch64, so as to check 98.1.0 is not
141        // offered. If that assumption fails then this test will fail with actual version being 98.1.0.
142        // (We use this combination because the OS and architecture enums don't allow for fake operating systems!)
143        let lookup = PluginRef::new(TEST_NAME, None);
144        let resolved = lookup
145            .resolve_manifest(&tests_store(), false, "98.0.0")
146            .await?;
147        assert_eq!("98.0.0", resolved.version);
148        Ok(())
149    }
150
151    #[tokio::test]
152    async fn if_version_given_it_gets_used_regardless() -> PluginLookupResult<()> {
153        let lookup = PluginRef::new(TEST_NAME, Some(semver::Version::parse("99.0.0").unwrap()));
154        let resolved = lookup
155            .resolve_manifest(&tests_store(), false, "98.0.0")
156            .await?;
157        assert_eq!("99.0.0", resolved.version);
158        Ok(())
159    }
160
161    #[tokio::test]
162    async fn if_latest_version_given_it_gets_used_regardless() -> PluginLookupResult<()> {
163        let lookup = PluginRef::new(TEST_NAME, Some(semver::Version::parse("99.0.1").unwrap()));
164        let resolved = lookup
165            .resolve_manifest(&tests_store(), false, "98.0.0")
166            .await?;
167        assert_eq!("99.0.1", resolved.version);
168        Ok(())
169    }
170
171    #[tokio::test]
172    async fn if_no_version_given_but_skip_compat_then_highest() -> PluginLookupResult<()> {
173        let lookup = PluginRef::new(TEST_NAME, None);
174        let resolved = lookup
175            .resolve_manifest(&tests_store(), true, "98.0.0")
176            .await?;
177        assert_eq!("99.0.1", resolved.version);
178        Ok(())
179    }
180
181    #[tokio::test]
182    async fn if_non_existent_version_given_then_error() -> PluginLookupResult<()> {
183        let lookup = PluginRef::new(TEST_NAME, Some(semver::Version::parse("177.7.7").unwrap()));
184        lookup
185            .resolve_manifest(&tests_store(), true, "99.0.0")
186            .await
187            .expect_err("Should have errored because plugin v177.7.7 does not exist");
188        Ok(())
189    }
190}