1use crate::{
2 SPIN_INTERNAL_COMMANDS,
3 error::*,
4 lookup::PluginRef,
5 manifest::{PluginManifest, PluginPackage, warn_unsupported_version},
6 store::PluginStore,
7};
8
9use anyhow::{Context, Result, anyhow, bail};
10use path_absolutize::Absolutize;
11use reqwest::{Client, header::HeaderMap};
12use serde::Serialize;
13use spin_common::sha256;
14use std::{
15 cmp::Ordering,
16 fs::{self, File},
17 io::{Cursor, copy},
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(PluginRef),
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 {
70 store: PluginStore,
71}
72
73impl PluginManager {
74 pub fn try_default() -> anyhow::Result<Self> {
76 let store = PluginStore::try_default()?;
77 Ok(Self { store })
78 }
79
80 pub async fn install(
87 &self,
88 plugin_manifest: &PluginManifest,
89 plugin_package: &PluginPackage,
90 source: &ManifestLocation,
91 auth_header_value: &Option<String>,
92 ) -> Result<String> {
93 let target = plugin_package.url.to_owned();
94 let target_url = Url::parse(&target)?;
95 let temp_dir = tempdir()?;
96 let plugin_tarball_path = match target_url.scheme() {
97 URL_FILE_SCHEME => {
98 let path = target_url
99 .to_file_path()
100 .map_err(|_| anyhow!("Invalid file URL: {target_url:?}"))?;
101 if path.is_file() {
102 path
103 } else {
104 bail!(
105 "Package path {} does not exist or is not a file",
106 path.display()
107 );
108 }
109 }
110 _ => {
111 download_plugin(
112 &plugin_manifest.name(),
113 &temp_dir,
114 &target,
115 auth_header_value,
116 )
117 .await?
118 }
119 };
120 verify_checksum(&plugin_tarball_path, &plugin_package.sha256)?;
121
122 self.store
123 .untar_plugin(&plugin_tarball_path, &plugin_manifest.name())
124 .with_context(|| format!("Failed to untar {}", plugin_tarball_path.display()))?;
125
126 self.store.add_manifest(plugin_manifest)?;
128 self.write_install_record(&plugin_manifest.name(), source);
129
130 Ok(plugin_manifest.name())
131 }
132
133 pub async fn install_latest(&self, name: &str, spin_version: &str) -> anyhow::Result<String> {
139 let manifest_location = ManifestLocation::PluginsRepository(PluginRef {
140 name: name.to_string(),
141 version: None,
142 });
143 let plugin_manifest = self
144 .get_manifest(&manifest_location, false, spin_version, &None)
145 .await?;
146 let plugin_package = plugin_manifest
147 .get_package()
148 .context("Plugin does not contain a compatible package")?;
149 self.install(&plugin_manifest, plugin_package, &manifest_location, &None)
150 .await
151 }
152
153 pub fn uninstall(&self, plugin_name: &str) -> Result<bool> {
157 let plugin_store = &self.store;
158 let manifest_file = plugin_store.installed_manifest_path(plugin_name);
159 let exists = manifest_file.exists();
160 if exists {
161 fs::remove_file(manifest_file)?;
163 fs::remove_dir_all(plugin_store.plugin_subdirectory_path(plugin_name))?;
164 }
165 Ok(exists)
166 }
167
168 pub fn check_manifest(
172 &self,
173 plugin_manifest: &PluginManifest,
174 spin_version: &str,
175 override_compatibility_check: bool,
176 allow_downgrades: bool,
177 ) -> Result<InstallAction> {
178 if SPIN_INTERNAL_COMMANDS
180 .iter()
181 .any(|&s| s == plugin_manifest.name())
182 {
183 bail!(
184 "Can't install a plugin with the same name ('{}') as an internal command",
185 plugin_manifest.name()
186 );
187 }
188
189 if let Ok(installed) = self.get_installed_manifest(&plugin_manifest.name()) {
191 if &installed == plugin_manifest {
192 return Ok(InstallAction::NoAction {
193 name: plugin_manifest.name(),
194 version: installed.version,
195 });
196 } else if installed.compare_versions(plugin_manifest) == Some(Ordering::Greater)
197 && !allow_downgrades
198 {
199 bail!(
200 "Newer version {} of plugin '{}' is already installed. To downgrade to version {}, run `spin plugins upgrade` with the `--downgrade` flag.",
201 installed.version,
202 plugin_manifest.name(),
203 plugin_manifest.version,
204 );
205 }
206 }
207
208 warn_unsupported_version(plugin_manifest, spin_version, override_compatibility_check)?;
209
210 Ok(InstallAction::Continue)
211 }
212
213 pub async fn get_manifest(
216 &self,
217 manifest_location: &ManifestLocation,
218 skip_compatibility_check: bool,
219 spin_version: &str,
220 auth_header_value: &Option<String>,
221 ) -> PluginLookupResult<PluginManifest> {
222 let plugin_manifest = match manifest_location {
223 ManifestLocation::Remote(url) => {
224 tracing::info!("Pulling manifest for plugin from {url}");
225 let client = Client::new();
226 client
227 .get(url.as_ref())
228 .headers(request_headers(auth_header_value)?)
229 .send()
230 .await
231 .map_err(|e| {
232 Error::ConnectionFailed(ConnectionFailedError::new(
233 url.as_str().to_string(),
234 e.to_string(),
235 ))
236 })?
237 .error_for_status()
238 .map_err(|e| {
239 Error::ConnectionFailed(ConnectionFailedError::new(
240 url.as_str().to_string(),
241 e.to_string(),
242 ))
243 })?
244 .json::<PluginManifest>()
245 .await
246 .map_err(|e| {
247 Error::InvalidManifest(InvalidManifestError::new(
248 None,
249 url.as_str().to_string(),
250 e.to_string(),
251 ))
252 })?
253 }
254 ManifestLocation::Local(path) => {
255 tracing::info!("Pulling manifest for plugin from {}", path.display());
256 let file = File::open(path).map_err(|e| {
257 Error::NotFound(NotFoundError::new(
258 None,
259 path.display().to_string(),
260 e.to_string(),
261 ))
262 })?;
263 serde_json::from_reader(file).map_err(|e| {
264 Error::InvalidManifest(InvalidManifestError::new(
265 None,
266 path.display().to_string(),
267 e.to_string(),
268 ))
269 })?
270 }
271 ManifestLocation::PluginsRepository(lookup) => {
272 lookup
273 .resolve_manifest(&self.catalogue(), skip_compatibility_check, spin_version)
274 .await?
275 }
276 };
277 Ok(plugin_manifest)
278 }
279
280 pub fn get_installed_manifest(&self, plugin_name: &str) -> PluginLookupResult<PluginManifest> {
283 let manifest_path = self.store.installed_manifest_path(plugin_name);
284 tracing::info!("Reading plugin manifest from {}", manifest_path.display());
285 let manifest_file = File::open(manifest_path.clone()).map_err(|e| {
286 Error::NotFound(NotFoundError::new(
287 Some(plugin_name.to_string()),
288 manifest_path.display().to_string(),
289 e.to_string(),
290 ))
291 })?;
292 let manifest = serde_json::from_reader(manifest_file).map_err(|e| {
293 Error::InvalidManifest(InvalidManifestError::new(
294 Some(plugin_name.to_string()),
295 manifest_path.display().to_string(),
296 e.to_string(),
297 ))
298 })?;
299 Ok(manifest)
300 }
301
302 pub fn is_empty(&self) -> bool {
303 let manifests_dir = self.store.installed_manifests_directory();
304 if !manifests_dir.exists() {
305 return true;
306 }
307 let Ok(mut rd) = manifests_dir.read_dir() else {
308 return true;
309 };
310 rd.next().is_none()
311 }
312
313 pub fn installed_plugins(&self) -> anyhow::Result<Vec<PluginManifest>> {
314 let manifests_dir = self.store.installed_manifests_directory();
315 let manifest_paths = crate::util::json_files_in(&manifests_dir);
316 let manifests = manifest_paths
317 .iter()
318 .filter_map(|path| crate::util::try_read_manifest_from(path))
319 .collect();
320 Ok(manifests)
321 }
322
323 pub async fn installed_plugins_latest_versions(
324 &self,
325 skip_compatibility_check: bool,
326 spin_version: &str,
327 auth_header_value: &Option<String>,
328 ) -> anyhow::Result<Vec<(PluginManifest, ManifestLocation)>> {
329 let mut plugins = vec![];
330
331 let manifests_dir = self.store.installed_manifests_directory();
332
333 for plugin in std::fs::read_dir(manifests_dir)? {
334 let path = plugin?.path();
335 let name = path
336 .file_stem()
337 .ok_or_else(|| anyhow!("No stem for path {}", path.display()))?
338 .to_str()
339 .ok_or_else(|| anyhow!("Cannot convert path {} stem to str", path.display()))?
340 .to_string();
341 let manifest_location =
342 ManifestLocation::PluginsRepository(PluginRef::new(&name, None));
343 let manifest = match self
344 .get_manifest(
345 &manifest_location,
346 skip_compatibility_check,
347 spin_version,
348 auth_header_value,
349 )
350 .await
351 {
352 Err(Error::NotFound(e)) => {
353 tracing::info!("Could not upgrade plugin '{name}': {e:?}");
354 continue;
355 }
356 Err(e) => return Err(e.into()),
357 Ok(m) => m,
358 };
359
360 plugins.push((manifest, manifest_location));
361 }
362
363 Ok(plugins)
364 }
365
366 pub fn is_installed(&self, plugin_name: &str) -> bool {
367 self.installed_plugins()
368 .unwrap_or_default()
369 .iter()
370 .any(|m| m.name() == plugin_name)
371 }
372
373 pub fn is_installed_exact(&self, manifest: &PluginManifest) -> bool {
374 match self.get_installed_manifest(&manifest.name()) {
375 Ok(m) => m.eq(manifest),
376 Err(_) => false,
377 }
378 }
379
380 pub async fn update(&self) -> Result<()> {
381 let mut locker = self.update_lock().await;
382 let guard = locker.lock_updates();
383 if guard.denied() {
384 anyhow::bail!("Another plugin update operation is already in progress");
385 }
386
387 let url = crate::catalogue::plugins_repo_url()?;
388 self.catalogue().fetch_from_remote(&url).await?;
389 Ok(())
390 }
391
392 async fn update_lock(&self) -> PluginManagerUpdateLock {
393 let lock = self.update_lock_impl().await;
394 PluginManagerUpdateLock::from(lock)
395 }
396
397 async fn update_lock_impl(&self) -> anyhow::Result<fd_lock::RwLock<tokio::fs::File>> {
398 let plugins_dir = self.store.get_plugins_directory();
399 tokio::fs::create_dir_all(plugins_dir).await?;
400 let file = tokio::fs::File::create(plugins_dir.join(".updatelock")).await?;
401 let locker = fd_lock::RwLock::new(file);
402 Ok(locker)
403 }
404
405 pub fn catalogue(&self) -> crate::Catalogue {
406 self.store.catalogue()
407 }
408
409 pub fn installed_binary_path(&self, plugin_name: &str) -> PathBuf {
410 self.store.installed_binary_path(plugin_name)
411 }
412
413 fn write_install_record(&self, plugin_name: &str, source: &ManifestLocation) {
414 let install_record_path = self.store.installation_record_file(plugin_name);
415
416 let install_record = source.to_install_record();
418 if let Ok(record_text) = serde_json::to_string_pretty(&install_record) {
419 _ = std::fs::write(install_record_path, record_text);
420 }
421 }
422}
423
424pub enum PluginManagerUpdateLock {
429 Lock(fd_lock::RwLock<tokio::fs::File>),
430 Failed,
431}
432
433impl From<anyhow::Result<fd_lock::RwLock<tokio::fs::File>>> for PluginManagerUpdateLock {
434 fn from(value: anyhow::Result<fd_lock::RwLock<tokio::fs::File>>) -> Self {
435 match value {
436 Ok(lock) => Self::Lock(lock),
437 Err(_) => Self::Failed,
438 }
439 }
440}
441
442impl PluginManagerUpdateLock {
443 pub fn lock_updates(&mut self) -> PluginManagerUpdateGuard<'_> {
444 match self {
445 Self::Lock(lock) => match lock.try_write() {
446 Ok(guard) => PluginManagerUpdateGuard::Acquired(guard),
447 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
448 PluginManagerUpdateGuard::Denied
449 }
450 _ => PluginManagerUpdateGuard::Failed,
451 },
452 Self::Failed => PluginManagerUpdateGuard::Failed,
453 }
454 }
455}
456
457#[must_use]
458pub enum PluginManagerUpdateGuard<'lock> {
459 Acquired(fd_lock::RwLockWriteGuard<'lock, tokio::fs::File>),
460 Denied,
461 Failed, }
463
464impl PluginManagerUpdateGuard<'_> {
465 pub fn denied(&self) -> bool {
466 matches!(self, Self::Denied)
467 }
468}
469
470pub enum InstallAction {
472 Continue,
474 NoAction { name: String, version: String },
476}
477
478async fn download_plugin(
479 name: &str,
480 temp_dir: &TempDir,
481 target_url: &str,
482 auth_header_value: &Option<String>,
483) -> Result<PathBuf> {
484 tracing::trace!("Trying to get tar file for plugin '{name}' from {target_url}");
485 let client = Client::new();
486 let plugin_bin = client
487 .get(target_url)
488 .headers(request_headers(auth_header_value)?)
489 .send()
490 .await?;
491 if !plugin_bin.status().is_success() {
492 match plugin_bin.status() {
493 reqwest::StatusCode::NOT_FOUND => bail!(
494 "The download URL specified in the plugin manifest was not found ({target_url} returned HTTP error 404). Please contact the plugin author."
495 ),
496 _ => bail!(
497 "HTTP error {} when downloading plugin from {target_url}",
498 plugin_bin.status()
499 ),
500 }
501 }
502
503 let mut content = Cursor::new(plugin_bin.bytes().await?);
504 let dir = temp_dir.path();
505 let mut plugin_file = dir.join(name);
506 plugin_file.set_extension("tar.gz");
507 let mut temp_file = File::create(&plugin_file)?;
508 copy(&mut content, &mut temp_file)?;
509 Ok(plugin_file)
510}
511
512fn verify_checksum(plugin_file: &Path, expected_sha256: &str) -> Result<()> {
513 let actual_sha256 = sha256::hex_digest_from_file(plugin_file)
514 .with_context(|| format!("Cannot get digest for {}", plugin_file.display()))?;
515 if actual_sha256 == expected_sha256 {
516 tracing::info!("Package checksum verified successfully");
517 Ok(())
518 } else {
519 Err(anyhow!("Checksum did not match, aborting installation."))
520 }
521}
522
523fn request_headers(auth_header_value: &Option<String>) -> Result<HeaderMap> {
527 let mut headers = HeaderMap::new();
528 if let Some(auth_value) = auth_header_value {
529 headers.insert(reqwest::header::AUTHORIZATION, auth_value.parse()?);
530 }
531 Ok(headers)
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 #[tokio::test]
539 async fn good_error_when_tarball_404s() -> anyhow::Result<()> {
540 let temp_dir = tempdir()?;
541 let store = PluginStore::new(temp_dir.path());
542 let manager = PluginManager { store };
543
544 let bad_manifest: PluginManifest = serde_json::from_str(include_str!(
545 "../tests/nonexistent-url/nonexistent-url.json"
546 ))?;
547
548 let install_result = manager
549 .install(
550 &bad_manifest,
551 &bad_manifest.packages[0],
552 &ManifestLocation::Local(PathBuf::from(
553 "../tests/nonexistent-url/nonexistent-url.json",
554 )),
555 &None,
556 )
557 .await;
558
559 let err = format!("{:#}", install_result.unwrap_err());
560 assert!(
561 err.contains("not found"),
562 "Expected error to contain 'not found' but was '{err}'"
563 );
564
565 Ok(())
566 }
567}