Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ spin-engine = { path = "crates/engine" }
spin-http-engine = { path = "crates/http" }
spin-loader = { path = "crates/loader" }
spin-manifest = { path = "crates/manifest" }
spin-plugins = { path = "crates/plugins" }
spin-publish = { path = "crates/publish" }
spin-redis-engine = { path = "crates/redis" }
spin-templates = { path = "crates/templates" }
Expand Down Expand Up @@ -78,6 +79,7 @@ members = [
"crates/manifest",
"crates/outbound-http",
"crates/outbound-redis",
"crates/plugins",
"crates/redis",
"crates/templates",
"crates/testing",
Expand Down
21 changes: 21 additions & 0 deletions crates/plugins/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "spin-plugins"
version = "0.1.0"
edition = "2021"

[dependencies]
anyhow = "1.0"
bytes = "1.1"
dirs = "4.0"
flate2 = "1.0"
log = { version = "0.4", default-features = false }
reqwest = { version = "0.11", features = ["json"] }
semver = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10.2"
tar = "0.4.38"
tempfile = "3.3.0"
thiserror = "1"
tokio = { version = "1.10", features = [ "fs", "process", "rt", "macros" ] }
url = "2.2.2"
89 changes: 89 additions & 0 deletions crates/plugins/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
pub type PluginLookupResult<T> = std::result::Result<T, Error>;
Comment thread
kate-goldenring marked this conversation as resolved.

/// Error message during plugin lookup or deserializing
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("{0}")]
NotFound(NotFoundError),

#[error("{0}")]
ConnectionFailed(ConnectionFailedError),

#[error("{0}")]
InvalidManifest(InvalidManifestError),

#[error("URL parse error {0}")]
UrlParseError(#[from] url::ParseError),
}

/// Contains error details for when a plugin resource cannot be found at expected location
#[derive(Debug)]
pub struct NotFoundError {
name: Option<String>,
addr: String,
err: String,
}

impl NotFoundError {
pub fn new(name: Option<String>, addr: String, err: String) -> Self {
Self { name, addr, err }
}
}

impl std::fmt::Display for NotFoundError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!(
"plugin '{}' not found at expected location {}: {}",
self.name.as_deref().unwrap_or_default(),
self.addr,
self.err
))
}
}

/// Contains error details for when a plugin manifest cannot be properly serialized
#[derive(Debug)]
pub struct InvalidManifestError {
name: Option<String>,
addr: String,
err: String,
}

impl InvalidManifestError {
pub fn new(name: Option<String>, addr: String, err: String) -> Self {
Self { name, addr, err }
}
}

impl std::fmt::Display for InvalidManifestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!(
"invalid manifest for plugin '{}' at {}: {}",
self.name.clone().unwrap_or_default(),
self.addr,
self.err
))
}
}

/// Contains error details for when there is an error getting a plugin resource from an address.
#[derive(Debug)]
pub struct ConnectionFailedError {
addr: String,
err: String,
}

impl ConnectionFailedError {
pub fn new(addr: String, err: String) -> Self {
Self { addr, err }
}
}

impl std::fmt::Display for ConnectionFailedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&format!(
"failed to connect to endpoint {}: {}",
self.addr, self.err
))
}
}
65 changes: 65 additions & 0 deletions crates/plugins/src/git.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
use anyhow::Result;
use std::path::{Path, PathBuf};
use tokio::process::Command;
use url::Url;

const DEFAULT_BRANCH: &str = "main";

/// Enables cloning and fetching the latest of a git repository to a local
/// directory.
pub struct GitSource {
/// Address to remote git repository.
source_url: Url,
/// Branch to clone/fetch.
branch: String,
/// Destination to clone repository into.
git_root: PathBuf,
}

impl GitSource {
/// Creates a new git source
pub fn new(source_url: &Url, branch: Option<String>, git_root: impl AsRef<Path>) -> GitSource {
Self {
source_url: source_url.clone(),
branch: branch.unwrap_or_else(|| DEFAULT_BRANCH.to_owned()),
git_root: git_root.as_ref().to_owned(),
}
}

/// Clones a contents of a git repository to a local directory
pub async fn clone_repo(&self) -> Result<()> {
let mut git = Command::new("git");
git.args([
"clone",
self.source_url.as_ref(),
"--branch",
&self.branch,
"--single-branch",
])
.arg(&self.git_root);
let clone_result = git.output().await?;
if !clone_result.status.success() {
anyhow::bail!(
"Error cloning Git repo {}: {}",
self.source_url,
String::from_utf8_lossy(&clone_result.stderr)
)
}
Ok(())
}

/// Fetches the latest changes from the source repository
pub async fn pull(&self) -> Result<()> {
let mut git = Command::new("git");
git.arg("-C").arg(&self.git_root).arg("pull");
let pull_result = git.output().await?;
if !pull_result.status.success() {
anyhow::bail!(
"Error updating Git repo at {}: {}",
self.git_root.display(),
String::from_utf8_lossy(&pull_result.stderr)
)
}
Ok(())
}
}
22 changes: 22 additions & 0 deletions crates/plugins/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
pub mod error;
mod git;
pub mod lookup;
pub mod manager;
pub mod manifest;
mod prompt;
mod store;
pub use prompt::prompt_confirm_install;
pub use store::PluginStore;

/// List of Spin internal subcommands
pub(crate) const SPIN_INTERNAL_COMMANDS: [&str; 9] = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there anything we can do, e.g. a test, to ensure this stays up to date?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing it out! Yes, we can implement tests using get_subcommands method provided by clap. We could even potentially replace the generation of the list of commands to be dynamic such that it does not need to be updated manually.

"templates",
"up",
"new",
"bindle",
"deploy",
"build",
"plugin",
"trigger",
"external",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any keywords we want to reserve so that no plugin can squat on them?

(Also raises the question: what will happen if someone creates, say, a spin test plugin, and we later want to build in a test command?)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if both a test internal command and plugin exist, it will be parsed as an internal subcommand and therefore the plugin cannot be invoked until the binary name is changed. Therefore, plugins cannot squat on internal subcommand names.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that's good to understand. Unfortunately, I didn't explain my concern clearly (and then muddied the waters with that second thought). What I was thinking of when I mentioned squatting was something like:

  • Alice creates a spin test plugin
  • Bob likes it and uses it in his CI system
  • Now we can't create a built-in spin test command because it would break Bob's CI, which would be rude

So if there are commands we're tossing up having, we might want to reserve the names. Hope that makes more sense now!

Also, might be worth having a way to force mapping to a plugin, e.g. spin external test or spin plugin run test, so that at least Bob can fix his CI without having to badger Alice to rename her plugin. That can be retrofitted if the plugin ecosystem merits it though!

@karthik2804 karthik2804 Sep 7, 2022

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense to me. I would assume that if we are releasing a new internal subcommand, the version of spin would change thus not allowing the external plugin to run in the first place without an update to the plugin. This would depend on the version compatibility specified by the plugin.

Although, I like the solution of an external or plugin run subcommand to force run plugins in such cases.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My only concern with that would be that we want to avoid plugins having the same name as the internal subcommands. Adding this option would make that test pointless. It might lead to confusion among users due to commands of the same name doing different things.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do! And it might! And the test is good because it prevents plugins having the same names as known commands. The trouble is, we can't stop a plugin having the same name as a command we haven't thought of yet. So: when we want to use a command name that is already used by an existing plugin, what happens?

As I say... we can probably punt on this for now. We may need to confront it if the plugin ecosystem takes off, and it would be good to have the shape of a solution ready. But we don't need to implement it yet.

];
107 changes: 107 additions & 0 deletions crates/plugins/src/lookup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
use crate::{error::*, git::GitSource, manifest::PluginManifest, store::manifest_file_name};
use semver::Version;
use std::{
fs::File,
path::{Path, PathBuf},
};
use url::Url;

// Name of directory that contains the cloned centralized Spin plugins
// repository
const PLUGINS_REPO_LOCAL_DIRECTORY: &str = ".spin-plugins";

// Name of directory containing the installed manifests
const PLUGINS_REPO_MANIFESTS_DIRECTORY: &str = "manifests";

const SPIN_PLUGINS_REPO: &str = "https://github.com/fermyon/spin-plugins/";

/// Looks up plugin manifests in centralized spin plugin repository.
pub struct PluginLookup {
pub name: String,
pub version: Option<Version>,
}

impl PluginLookup {
pub fn new(name: &str, version: Option<Version>) -> Self {
Self {
name: name.to_lowercase(),
version,
}
}

pub async fn get_manifest_from_repository(
&self,
plugins_dir: &Path,
) -> PluginLookupResult<PluginManifest> {
let url = plugins_repo_url()?;
log::info!("Pulling manifest for plugin {} from {url}", self.name);
fetch_plugins_repo(&url, plugins_dir, false)
.await
.map_err(|e| {
Error::ConnectionFailed(ConnectionFailedError::new(url.to_string(), e.to_string()))
})?;
let expected_path = spin_plugins_repo_manifest_path(&self.name, &self.version, plugins_dir);
let file = File::open(&expected_path).map_err(|e| {
Error::NotFound(NotFoundError::new(
Some(self.name.clone()),
expected_path.display().to_string(),
e.to_string(),
))
})?;
let manifest: PluginManifest = serde_json::from_reader(file).map_err(|e| {
Error::InvalidManifest(InvalidManifestError::new(
Some(self.name.clone()),
expected_path.display().to_string(),
e.to_string(),
))
})?;
Ok(manifest)
}
}

pub fn plugins_repo_url() -> Result<Url, url::ParseError> {
Url::parse(SPIN_PLUGINS_REPO)
}

pub async fn fetch_plugins_repo(
repo_url: &Url,
plugins_dir: &Path,
update: bool,
) -> anyhow::Result<()> {
let git_root = plugin_manifests_repo_path(plugins_dir);
let git_source = GitSource::new(repo_url, None, &git_root);
if git_root.join(".git").exists() {
if update {
git_source.pull().await?;
}
} else {
git_source.clone_repo().await?;
}
Ok(())
}

fn plugin_manifests_repo_path(plugins_dir: &Path) -> PathBuf {
plugins_dir.join(PLUGINS_REPO_LOCAL_DIRECTORY)
}

// Given a name and option version, outputs expected file name for the plugin.
fn manifest_file_name_version(plugin_name: &str, version: &Option<semver::Version>) -> String {
match version {
Some(v) => format!("{}@{}.json", plugin_name, v),
None => manifest_file_name(plugin_name),
}
}

/// Get expected path to the manifest of a plugin with a given name
/// and version within the spin-plugins repository
fn spin_plugins_repo_manifest_path(
plugin_name: &str,
plugin_version: &Option<Version>,
plugins_dir: &Path,
) -> PathBuf {
plugins_dir
.join(PLUGINS_REPO_LOCAL_DIRECTORY)
.join(PLUGINS_REPO_MANIFESTS_DIRECTORY)
.join(plugin_name)
.join(manifest_file_name_version(plugin_name, plugin_version))
}
Loading