spin_plugins/
manifest.rs

1use std::io::IsTerminal;
2
3use anyhow::{anyhow, Context, Result};
4use semver::{Version, VersionReq};
5use serde::{Deserialize, Serialize};
6use url::Url;
7
8use crate::PluginStore;
9
10/// Expected schema of a plugin manifest. Should match the latest Spin plugin
11/// manifest JSON schema:
12/// <https://github.com/spinframework/spin-plugins/tree/main/json-schema>
13#[derive(Serialize, Debug, Deserialize, PartialEq)]
14#[serde(rename_all = "camelCase")]
15pub struct PluginManifest {
16    /// Name of the plugin.
17    name: String,
18    /// Option description of the plugin.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    description: Option<String>,
21    /// Optional address to the homepage of the plugin producer.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    homepage: Option<String>,
24    /// Version of the plugin.
25    pub(crate) version: String,
26    /// Versions of Spin that the plugin is compatible with.
27    pub(crate) spin_compatibility: String,
28    /// License of the plugin.
29    license: String,
30    /// Points to source package[s] of the plugin..
31    pub(crate) packages: Vec<PluginPackage>,
32}
33
34impl PluginManifest {
35    pub fn name(&self) -> String {
36        self.name.to_lowercase()
37    }
38    pub fn version(&self) -> &str {
39        &self.version
40    }
41    pub fn license(&self) -> &str {
42        self.license.as_ref()
43    }
44
45    pub fn spin_compatibility(&self) -> String {
46        self.spin_compatibility.clone()
47    }
48
49    pub fn description(&self) -> Option<&str> {
50        self.description.as_deref()
51    }
52
53    pub fn homepage_url(&self) -> Option<Url> {
54        Url::parse(self.homepage.as_deref()?).ok()
55    }
56
57    pub fn has_compatible_package(&self) -> bool {
58        self.packages.iter().any(|p| p.matches_current_os_arch())
59    }
60    pub fn is_compatible_spin_version(&self, spin_version: &str) -> bool {
61        is_version_compatible_enough(&self.spin_compatibility, spin_version).unwrap_or(false)
62    }
63    pub fn is_installed_in(&self, store: &PluginStore) -> bool {
64        match store.read_plugin_manifest(&self.name) {
65            Ok(m) => m.eq(self),
66            Err(_) => false,
67        }
68    }
69
70    pub fn try_version(&self) -> Result<semver::Version, semver::Error> {
71        semver::Version::parse(&self.version)
72    }
73
74    // Compares the versions. Returns None if either's version string is invalid semver.
75    pub fn compare_versions(&self, other: &Self) -> Option<std::cmp::Ordering> {
76        if let Ok(this_version) = self.try_version() {
77            if let Ok(other_version) = other.try_version() {
78                return Some(this_version.cmp_precedence(&other_version));
79            }
80        }
81        None
82    }
83}
84
85/// Describes compatibility and location of a plugin source.
86#[derive(Serialize, Debug, Deserialize, PartialEq)]
87pub struct PluginPackage {
88    /// Compatible OS.
89    pub(crate) os: Os,
90    /// Compatible architecture.
91    pub(crate) arch: Architecture,
92    /// Address to fetch the plugin source tar file.
93    pub(crate) url: String,
94    /// Checksum to verify the plugin before installation.
95    pub(crate) sha256: String,
96}
97
98impl PluginPackage {
99    pub fn url(&self) -> String {
100        self.url.clone()
101    }
102    pub fn matches_current_os_arch(&self) -> bool {
103        self.os.rust_name() == std::env::consts::OS
104            && self.arch.rust_name() == std::env::consts::ARCH
105    }
106}
107
108/// Describes the compatible OS of a plugin
109#[derive(Serialize, Debug, Deserialize, PartialEq)]
110#[serde(rename_all = "camelCase")]
111pub(crate) enum Os {
112    Linux,
113    Macos,
114    Windows,
115}
116
117impl Os {
118    // Maps manifest OS options to associated Rust OS strings
119    // https://doc.rust-lang.org/std/env/consts/constant.OS.html
120    pub(crate) fn rust_name(&self) -> &'static str {
121        match self {
122            Os::Linux => "linux",
123            Os::Macos => "macos",
124            Os::Windows => "windows",
125        }
126    }
127}
128
129/// Describes the compatible architecture of a plugin
130#[derive(Serialize, Debug, Deserialize, PartialEq)]
131#[serde(rename_all = "camelCase")]
132pub(crate) enum Architecture {
133    Amd64,
134    Aarch64,
135    Arm,
136}
137
138impl Architecture {
139    // Maps manifest Architecture options to associated Rust ARCH strings
140    // https://doc.rust-lang.org/std/env/consts/constant.ARCH.html
141    pub(crate) fn rust_name(&self) -> &'static str {
142        match self {
143            Architecture::Amd64 => "x86_64",
144            Architecture::Aarch64 => "aarch64",
145            Architecture::Arm => "arm",
146        }
147    }
148}
149
150/// Checks whether the plugin supports the currently running version of Spin
151/// and prints a warning if not (or if uncertain).
152pub fn warn_unsupported_version(
153    manifest: &PluginManifest,
154    spin_version: &str,
155    override_compatibility_check: bool,
156) -> Result<()> {
157    let supported_on = &manifest.spin_compatibility;
158    inner_warn_unsupported_version(supported_on, spin_version, override_compatibility_check)
159}
160
161/// Does the manifest compatibility pattern match this version of Spin?  This is a
162/// strict semver check.
163fn is_version_fully_compatible(supported_on: &str, spin_version: &str) -> Result<bool> {
164    let comparator = VersionReq::parse(supported_on).with_context(|| {
165        format!("Could not parse manifest compatibility version {supported_on} as valid semver")
166    })?;
167    let version = Version::parse(spin_version)?;
168    Ok(comparator.matches(&version))
169}
170
171/// This is more liberal than `is_version_fully_compatible`; it relaxes the semver requirement
172/// for Spin pre-releases, so that you don't get *every* plugin showing as incompatible when
173/// you run a pre-release.  This is intended for listing; when executing, we use the interactive
174/// `warn_unsupported_version`, which provides the full nuanced feedback.
175pub(crate) fn is_version_compatible_enough(supported_on: &str, spin_version: &str) -> Result<bool> {
176    if is_version_fully_compatible(supported_on, spin_version)? {
177        Ok(true)
178    } else {
179        // We allow things to run on pre-release versions, because otherwise EVERYTHING would
180        // show as incompatible!
181        let is_spin_prerelease = Version::parse(spin_version)
182            .map(|v| !v.pre.is_empty())
183            .unwrap_or_default();
184        Ok(is_spin_prerelease)
185    }
186}
187
188fn inner_warn_unsupported_version(
189    supported_on: &str,
190    spin_version: &str,
191    override_compatibility_check: bool,
192) -> Result<()> {
193    if !is_version_fully_compatible(supported_on, spin_version)? {
194        let show_warnings = !suppress_compatibility_warnings();
195        let version = Version::parse(spin_version)?;
196        if !version.pre.is_empty() {
197            if std::io::stderr().is_terminal() && show_warnings {
198                terminal::warn!("You're using a pre-release version of Spin ({spin_version}). This plugin might not be compatible (supported: {supported_on}). Continuing anyway.");
199            }
200        } else if override_compatibility_check {
201            if show_warnings {
202                terminal::warn!("Plugin is not compatible with this version of Spin (supported: {supported_on}, actual: {spin_version}). Check overridden ... continuing to install or execute plugin.");
203            }
204        } else {
205            return Err(anyhow!(
206            "Plugin is not compatible with this version of Spin (supported: {supported_on}, actual: {spin_version}). Try running `spin plugins update && spin plugins upgrade --all` to install latest or override with `--override-compatibility-check`."
207        ));
208        }
209    }
210    Ok(())
211}
212
213fn suppress_compatibility_warnings() -> bool {
214    match std::env::var("SPIN_PLUGINS_SUPPRESS_COMPATIBILITY_WARNINGS") {
215        Ok(s) => !s.is_empty(),
216        Err(_) => false,
217    }
218}
219
220#[cfg(test)]
221mod test {
222    use super::*;
223
224    fn generate_test_manifest(
225        name: &str,
226        version: &str,
227        license: &str,
228        description: Option<&str>,
229        homepage: Option<&str>,
230    ) -> PluginManifest {
231        let mut plugin_json = serde_json::json!(
232        {
233            "name": name,
234            "version": version,
235            "spinCompatibility": "=0.4",
236            "license": license,
237            "packages": [
238                {
239                    "os": "linux",
240                    "arch": "amd64",
241                    "url": "www.example.com/releases/1.0/binary.tgz",
242                    "sha256": "c474f00b12345e38acae2d19b2a707a4fhdjdfdd22875efeefdf052ce19c90b"
243                },
244                {
245                    "os": "windows",
246                    "arch": "amd64",
247                    "url": "www.example.com/releases/1.0/binary.tgz",
248                    "sha256": "eee4f00b12345e38acae2d19b2a707a4fhdjdfdd22875efeefdf052ce19c90b"
249                },
250                {
251                    "os": "macos",
252                    "arch": "aarch64",
253                    "url": "www.example.com/releases/1.0/binary.tgz",
254                    "sha256": "eeegf00b12345e38acae2d19b2a707a4fhdjdfdd22875efeefdf052ce19c90b"
255                }
256            ]
257        });
258        if let Some(homepage) = homepage {
259            plugin_json
260                .as_object_mut()
261                .unwrap()
262                .insert("homepage".to_string(), serde_json::json!(homepage));
263        }
264        if let Some(description) = description {
265            plugin_json
266                .as_object_mut()
267                .unwrap()
268                .insert("description".to_string(), serde_json::json!(description));
269        }
270        serde_json::from_value(plugin_json).unwrap()
271    }
272
273    #[test]
274    fn test_supported_version() {
275        let test_case = ">=1.2.3, <1.8.0";
276        let input_output = [
277            ("1.3.0", true),
278            ("1.2.3", true),
279            ("1.8.0", false),
280            ("1.9.0", false),
281            ("1.2.0", false),
282        ];
283        input_output.into_iter().for_each(|(i, o)| {
284            assert_eq!(
285                inner_warn_unsupported_version(test_case, i, false).is_ok(),
286                o
287            )
288        });
289    }
290
291    #[test]
292    fn test_plugin_json() {
293        let name = "test";
294        let description = "Some description.";
295        let homepage = "www.example.com";
296        let version = "1.0";
297        let license = "Mit";
298        let deserialized_plugin =
299            generate_test_manifest(name, version, license, Some(description), Some(homepage));
300        assert_eq!(deserialized_plugin.name(), name.to_owned());
301        assert_eq!(
302            deserialized_plugin.description,
303            Some(description.to_owned())
304        );
305        assert_eq!(deserialized_plugin.homepage, Some(homepage.to_owned()));
306        assert_eq!(deserialized_plugin.version, version.to_owned());
307        assert_eq!(deserialized_plugin.license, license.to_owned());
308        assert_eq!(deserialized_plugin.packages.len(), 3);
309    }
310
311    #[test]
312    fn test_plugin_json_empty_options() {
313        let deserialized_plugin = generate_test_manifest("name", "0.1.1", "Mit", None, None);
314        assert_eq!(deserialized_plugin.description, None);
315        assert_eq!(deserialized_plugin.homepage, None);
316    }
317}