1use crate::{
2 error::*,
3 lookup::PluginLookup,
4 manifest::{warn_unsupported_version, PluginManifest, PluginPackage},
5 store::PluginStore,
6 SPIN_INTERNAL_COMMANDS,
7};
8
9use anyhow::{anyhow, bail, Context, Result};
10use path_absolutize::Absolutize;
11use reqwest::{header::HeaderMap, Client};
12use serde::Serialize;
13use spin_common::sha256;
14use std::{
15 cmp::Ordering,
16 fs::{self, File},
17 io::{copy, Cursor},
18 path::{Path, PathBuf},
19};
20use tempfile::{tempdir, TempDir};
21use url::Url;
22
23const URL_FILE_SCHEME: &str = "file";
25
26pub enum ManifestLocation {
28 Local(PathBuf),
30 Remote(Url),
32 PluginsRepository(PluginLookup),
34}
35
36impl ManifestLocation {
37 pub(crate) fn to_install_record(&self) -> RawInstallRecord {
38 match self {
39 Self::Local(path) => {
40 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
64pub struct PluginManager {
66 store: PluginStore,
67}
68
69impl PluginManager {
70 pub fn try_default() -> anyhow::Result<Self> {
72 let store = PluginStore::try_default()?;
73 Ok(Self { store })
74 }
75
76 pub fn store(&self) -> &PluginStore {
78 &self.store
79 }
80
81 pub async fn install(
88 &self,
89 plugin_manifest: &PluginManifest,
90 plugin_package: &PluginPackage,
91 source: &ManifestLocation,
92 auth_header_value: &Option<String>,
93 ) -> Result<String> {
94 let target = plugin_package.url.to_owned();
95 let target_url = Url::parse(&target)?;
96 let temp_dir = tempdir()?;
97 let plugin_tarball_path = match target_url.scheme() {
98 URL_FILE_SCHEME => {
99 let path = target_url
100 .to_file_path()
101 .map_err(|_| anyhow!("Invalid file URL: {target_url:?}"))?;
102 if path.is_file() {
103 path
104 } else {
105 bail!(
106 "Package path {} does not exist or is not a file",
107 path.display()
108 );
109 }
110 }
111 _ => {
112 download_plugin(
113 &plugin_manifest.name(),
114 &temp_dir,
115 &target,
116 auth_header_value,
117 )
118 .await?
119 }
120 };
121 verify_checksum(&plugin_tarball_path, &plugin_package.sha256)?;
122
123 self.store
124 .untar_plugin(&plugin_tarball_path, &plugin_manifest.name())
125 .with_context(|| format!("Failed to untar {}", plugin_tarball_path.display()))?;
126
127 self.store.add_manifest(plugin_manifest)?;
129 self.write_install_record(&plugin_manifest.name(), source);
130
131 Ok(plugin_manifest.name())
132 }
133
134 pub fn uninstall(&self, plugin_name: &str) -> Result<bool> {
138 let plugin_store = self.store();
139 let manifest_file = plugin_store.installed_manifest_path(plugin_name);
140 let exists = manifest_file.exists();
141 if exists {
142 fs::remove_file(manifest_file)?;
144 fs::remove_dir_all(plugin_store.plugin_subdirectory_path(plugin_name))?;
145 }
146 Ok(exists)
147 }
148
149 pub fn check_manifest(
153 &self,
154 plugin_manifest: &PluginManifest,
155 spin_version: &str,
156 override_compatibility_check: bool,
157 allow_downgrades: bool,
158 ) -> Result<InstallAction> {
159 if SPIN_INTERNAL_COMMANDS
161 .iter()
162 .any(|&s| s == plugin_manifest.name())
163 {
164 bail!(
165 "Can't install a plugin with the same name ('{}') as an internal command",
166 plugin_manifest.name()
167 );
168 }
169
170 if let Ok(installed) = self.store.read_plugin_manifest(&plugin_manifest.name()) {
172 if &installed == plugin_manifest {
173 return Ok(InstallAction::NoAction {
174 name: plugin_manifest.name(),
175 version: installed.version,
176 });
177 } else if installed.compare_versions(plugin_manifest) == Some(Ordering::Greater)
178 && !allow_downgrades
179 {
180 bail!(
181 "Newer version {} of plugin '{}' is already installed. To downgrade to version {}, run `spin plugins upgrade` with the `--downgrade` flag.",
182 installed.version,
183 plugin_manifest.name(),
184 plugin_manifest.version,
185 );
186 }
187 }
188
189 warn_unsupported_version(plugin_manifest, spin_version, override_compatibility_check)?;
190
191 Ok(InstallAction::Continue)
192 }
193
194 pub async fn get_manifest(
197 &self,
198 manifest_location: &ManifestLocation,
199 skip_compatibility_check: bool,
200 spin_version: &str,
201 auth_header_value: &Option<String>,
202 ) -> PluginLookupResult<PluginManifest> {
203 let plugin_manifest = match manifest_location {
204 ManifestLocation::Remote(url) => {
205 tracing::info!("Pulling manifest for plugin from {url}");
206 let client = Client::new();
207 client
208 .get(url.as_ref())
209 .headers(request_headers(auth_header_value)?)
210 .send()
211 .await
212 .map_err(|e| {
213 Error::ConnectionFailed(ConnectionFailedError::new(
214 url.as_str().to_string(),
215 e.to_string(),
216 ))
217 })?
218 .error_for_status()
219 .map_err(|e| {
220 Error::ConnectionFailed(ConnectionFailedError::new(
221 url.as_str().to_string(),
222 e.to_string(),
223 ))
224 })?
225 .json::<PluginManifest>()
226 .await
227 .map_err(|e| {
228 Error::InvalidManifest(InvalidManifestError::new(
229 None,
230 url.as_str().to_string(),
231 e.to_string(),
232 ))
233 })?
234 }
235 ManifestLocation::Local(path) => {
236 tracing::info!("Pulling manifest for plugin from {}", path.display());
237 let file = File::open(path).map_err(|e| {
238 Error::NotFound(NotFoundError::new(
239 None,
240 path.display().to_string(),
241 e.to_string(),
242 ))
243 })?;
244 serde_json::from_reader(file).map_err(|e| {
245 Error::InvalidManifest(InvalidManifestError::new(
246 None,
247 path.display().to_string(),
248 e.to_string(),
249 ))
250 })?
251 }
252 ManifestLocation::PluginsRepository(lookup) => {
253 lookup
254 .resolve_manifest(
255 self.store().get_plugins_directory(),
256 skip_compatibility_check,
257 spin_version,
258 )
259 .await?
260 }
261 };
262 Ok(plugin_manifest)
263 }
264
265 pub async fn update_lock(&self) -> PluginManagerUpdateLock {
266 let lock = self.update_lock_impl().await;
267 PluginManagerUpdateLock::from(lock)
268 }
269
270 async fn update_lock_impl(&self) -> anyhow::Result<fd_lock::RwLock<tokio::fs::File>> {
271 let plugins_dir = self.store().get_plugins_directory();
272 tokio::fs::create_dir_all(plugins_dir).await?;
273 let file = tokio::fs::File::create(plugins_dir.join(".updatelock")).await?;
274 let locker = fd_lock::RwLock::new(file);
275 Ok(locker)
276 }
277
278 fn write_install_record(&self, plugin_name: &str, source: &ManifestLocation) {
279 let install_record_path = self.store.installation_record_file(plugin_name);
280
281 let install_record = source.to_install_record();
283 if let Ok(record_text) = serde_json::to_string_pretty(&install_record) {
284 _ = std::fs::write(install_record_path, record_text);
285 }
286 }
287}
288
289pub enum PluginManagerUpdateLock {
294 Lock(fd_lock::RwLock<tokio::fs::File>),
295 Failed,
296}
297
298impl From<anyhow::Result<fd_lock::RwLock<tokio::fs::File>>> for PluginManagerUpdateLock {
299 fn from(value: anyhow::Result<fd_lock::RwLock<tokio::fs::File>>) -> Self {
300 match value {
301 Ok(lock) => Self::Lock(lock),
302 Err(_) => Self::Failed,
303 }
304 }
305}
306
307impl PluginManagerUpdateLock {
308 pub fn lock_updates(&mut self) -> PluginManagerUpdateGuard<'_> {
309 match self {
310 Self::Lock(lock) => match lock.try_write() {
311 Ok(guard) => PluginManagerUpdateGuard::Acquired(guard),
312 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
313 PluginManagerUpdateGuard::Denied
314 }
315 _ => PluginManagerUpdateGuard::Failed,
316 },
317 Self::Failed => PluginManagerUpdateGuard::Failed,
318 }
319 }
320}
321
322#[must_use]
323pub enum PluginManagerUpdateGuard<'lock> {
324 Acquired(fd_lock::RwLockWriteGuard<'lock, tokio::fs::File>),
325 Denied,
326 Failed, }
328
329impl PluginManagerUpdateGuard<'_> {
330 pub fn denied(&self) -> bool {
331 matches!(self, Self::Denied)
332 }
333}
334
335pub enum InstallAction {
337 Continue,
339 NoAction { name: String, version: String },
341}
342
343pub fn get_package(plugin_manifest: &PluginManifest) -> Result<&PluginPackage> {
345 use std::env::consts::{ARCH, OS};
346 plugin_manifest
347 .packages
348 .iter()
349 .find(|p| p.os.rust_name() == OS && p.arch.rust_name() == ARCH)
350 .ok_or_else(|| {
351 anyhow!("This plugin does not support this OS ({OS}) or architecture ({ARCH}).")
352 })
353}
354
355async fn download_plugin(
356 name: &str,
357 temp_dir: &TempDir,
358 target_url: &str,
359 auth_header_value: &Option<String>,
360) -> Result<PathBuf> {
361 tracing::trace!("Trying to get tar file for plugin '{name}' from {target_url}");
362 let client = Client::new();
363 let plugin_bin = client
364 .get(target_url)
365 .headers(request_headers(auth_header_value)?)
366 .send()
367 .await?;
368 if !plugin_bin.status().is_success() {
369 match plugin_bin.status() {
370 reqwest::StatusCode::NOT_FOUND => bail!("The download URL specified in the plugin manifest was not found ({target_url} returned HTTP error 404). Please contact the plugin author."),
371 _ => bail!("HTTP error {} when downloading plugin from {target_url}", plugin_bin.status()),
372 }
373 }
374
375 let mut content = Cursor::new(plugin_bin.bytes().await?);
376 let dir = temp_dir.path();
377 let mut plugin_file = dir.join(name);
378 plugin_file.set_extension("tar.gz");
379 let mut temp_file = File::create(&plugin_file)?;
380 copy(&mut content, &mut temp_file)?;
381 Ok(plugin_file)
382}
383
384fn verify_checksum(plugin_file: &Path, expected_sha256: &str) -> Result<()> {
385 let actual_sha256 = sha256::hex_digest_from_file(plugin_file)
386 .with_context(|| format!("Cannot get digest for {}", plugin_file.display()))?;
387 if actual_sha256 == expected_sha256 {
388 tracing::info!("Package checksum verified successfully");
389 Ok(())
390 } else {
391 Err(anyhow!("Checksum did not match, aborting installation."))
392 }
393}
394
395fn request_headers(auth_header_value: &Option<String>) -> Result<HeaderMap> {
399 let mut headers = HeaderMap::new();
400 if let Some(auth_value) = auth_header_value {
401 headers.insert(reqwest::header::AUTHORIZATION, auth_value.parse()?);
402 }
403 Ok(headers)
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[tokio::test]
411 async fn good_error_when_tarball_404s() -> anyhow::Result<()> {
412 let temp_dir = tempdir()?;
413 let store = PluginStore::new(temp_dir.path());
414 let manager = PluginManager { store };
415
416 let bad_manifest: PluginManifest = serde_json::from_str(include_str!(
417 "../tests/nonexistent-url/nonexistent-url.json"
418 ))?;
419
420 let install_result = manager
421 .install(
422 &bad_manifest,
423 &bad_manifest.packages[0],
424 &ManifestLocation::Local(PathBuf::from(
425 "../tests/nonexistent-url/nonexistent-url.json",
426 )),
427 &None,
428 )
429 .await;
430
431 let err = format!("{:#}", install_result.unwrap_err());
432 assert!(
433 err.contains("not found"),
434 "Expected error to contain 'not found' but was '{err}'"
435 );
436
437 Ok(())
438 }
439}