Skip to main content

spin_plugins/
manifest.rs

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