wash_lib/plugin/
subcommand.rs

1mod bindings {
2    wasmtime::component::bindgen!({
3        world: "subcommands",
4        async: true,
5    });
6}
7
8pub use bindings::exports::wasmcloud::wash::subcommand::Metadata;
9use bindings::Subcommands;
10
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Ok};
15use wasmtime::component::{Component, Linker};
16use wasmtime::{Config, Engine};
17use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder};
18use wasmtime_wasi_http::WasiHttpCtx;
19
20use super::Data;
21
22const DIRECTORY_ALLOW: DirPerms = DirPerms::all();
23const DIRECTORY_DENY: DirPerms = DirPerms::READ;
24
25struct InstanceData {
26    instance: Subcommands,
27    metadata: Metadata,
28    loaded_path: PathBuf,
29    store: wasmtime::Store<Data>,
30}
31
32/// A struct that manages loading and running subcommand plugins
33pub struct SubcommandRunner {
34    engine: Engine,
35    plugins: HashMap<String, InstanceData>,
36}
37
38/// Host directory mapping to provide to plugins
39pub struct DirMapping {
40    /// The path on the host that should be opened. If this is a file, its parent directory will be
41    /// added with no RW access, but with RW access to the files in that directory. If it is a
42    /// directory, it will be added with RW access to that directory
43    pub host_path: PathBuf,
44    /// The path that will be accessible in the component. Otherwise defaults to the `host_path`
45    pub component_path: Option<String>,
46}
47
48impl SubcommandRunner {
49    /// Creates a new subcommand runner with no plugins loaded.
50    pub fn new() -> anyhow::Result<Self> {
51        let mut config = Config::new();
52        // Attempt to use caching, but only warn if it fails
53        if let Err(e) = config.cache_config_load_default() {
54            tracing::warn!(err = ?e, "Failed to load wasm cache");
55        }
56        config.wasm_component_model(true);
57        config.async_support(true);
58        let engine = Engine::new(&config)?;
59        Ok(Self {
60            engine,
61            plugins: HashMap::new(),
62        })
63    }
64
65    /// Create a new runner initialized with the list of plugins provided.
66    ///
67    /// This function will fail if any of the plugins fail to load. If you want to gracefully handle
68    /// errors, use [`add_plugin`](Self::add_plugin) instead.
69    pub async fn new_with_plugins(
70        plugins: impl IntoIterator<Item = impl AsRef<Path>>,
71    ) -> anyhow::Result<Self> {
72        let mut runner = Self::new()?;
73        for plugin in plugins {
74            runner.add_plugin(plugin).await?;
75        }
76        Ok(runner)
77    }
78
79    /// Adds a plugin to the runner, returning the metadata for the plugin and otherwise returning
80    /// an error if there was a problem loading the plugin. This can happen due to bad instantiation
81    /// or if a plugin with the same ID has already been loaded. As such, errors from this function
82    /// should be treated as a warning as execution can continue
83    pub async fn add_plugin(&mut self, path: impl AsRef<Path>) -> anyhow::Result<Metadata> {
84        self.add_plugin_internal(path, false).await
85    }
86
87    /// Same as [`add_plugin`](Self::add_plugin), but will not return an error if the plugin exists,
88    /// instead updating the metadata for the plugin. This is an upsert operation and will register
89    /// the plugin if it does not exist.
90    pub async fn update_plugin(&mut self, path: impl AsRef<Path>) -> anyhow::Result<Metadata> {
91        self.add_plugin_internal(path, true).await
92    }
93
94    async fn add_plugin_internal(
95        &mut self,
96        path: impl AsRef<Path>,
97        update: bool,
98    ) -> anyhow::Result<Metadata> {
99        // We create a bare context here for registration and then update the store with a new context before running
100        let ctx = WasiCtxBuilder::new().build();
101
102        let ctx = Data {
103            table: wasmtime::component::ResourceTable::default(),
104            ctx,
105            http: WasiHttpCtx::new(),
106        };
107
108        let mut store = wasmtime::Store::new(&self.engine, ctx);
109
110        let component = Component::from_file(&self.engine, &path)?;
111        let mut linker = Linker::new(&self.engine);
112        wasmtime_wasi::add_to_linker_async(&mut linker)
113            .context("failed to link core WASI interfaces")?;
114        wasmtime_wasi_http::add_only_http_to_linker_async(&mut linker)
115            .context("failed to link `wasi:http`")?;
116
117        let instance = Subcommands::instantiate_async(&mut store, &component, &linker).await?;
118        let metadata = instance
119            .wasmcloud_wash_subcommand()
120            .call_register(&mut store)
121            .await?;
122        let maybe_existing = self.plugins.insert(
123            metadata.id.clone(),
124            InstanceData {
125                instance,
126                metadata: metadata.clone(),
127                loaded_path: path.as_ref().to_owned(),
128                store,
129            },
130        );
131
132        match (update, maybe_existing) {
133            // If we're updating and the plugin exists already, overwrite is ok.
134            (true, _) | (false, None) => Ok(metadata),
135            // If update isn't set, then we don't allow the update
136            (false, Some(plugin)) => {
137                // Insert the existing plugin back into the map
138                let id = plugin.metadata.id.clone();
139                self.plugins.insert(plugin.metadata.id.clone(), plugin);
140                Err(anyhow::anyhow!("Plugin with id {id} already exists"))
141            }
142        }
143    }
144
145    /// Get the metadata for a plugin with the given ID if it exists.
146    pub fn metadata(&self, id: &str) -> Option<&Metadata> {
147        self.plugins.get(id).map(|p| &p.metadata)
148    }
149
150    /// Returns a list of all metadata for all plugins.
151    pub fn all_metadata(&self) -> Vec<&Metadata> {
152        self.plugins.values().map(|data| &data.metadata).collect()
153    }
154
155    /// Returns the path to the plugin with the given ID.
156    pub fn path(&self, id: &str) -> Option<&Path> {
157        self.plugins.get(id).map(|p| p.loaded_path.as_path())
158    }
159
160    /// Run a subcommand with the given name and args. The plugin will inherit all
161    /// stdout/stderr/stdin/env. The given plugin_dirs will be mapped into the plugin after
162    /// canonicalizing all paths and normalizing them to use `/` instead of `\`. An error will only
163    /// be returned if there was a problem with the plugin (such as the plugin dirs not existing or
164    /// failure to canonicalize) or the subcommand itself.
165    ///
166    /// All plugins will be passed environment variables starting with
167    /// `WASH_PLUGIN_${plugin_id.to_upper()}_` from the current process. Other vars will be ignored
168    pub async fn run(
169        &mut self,
170        plugin_id: &str,
171        plugin_dir: PathBuf,
172        dirs: Vec<DirMapping>,
173        mut args: Vec<String>,
174    ) -> anyhow::Result<()> {
175        let plugin = self
176            .plugins
177            .get_mut(plugin_id)
178            .ok_or_else(|| anyhow::anyhow!("Plugin with id {plugin_id} does not exist"))?;
179
180        let env_prefix = format!("WASH_PLUGIN_{}_", plugin_id.to_uppercase());
181        let vars: Vec<_> = std::env::vars()
182            .filter(|(k, _)| k.starts_with(&env_prefix))
183            .collect();
184        let mut ctx = WasiCtxBuilder::new();
185        for dir in dirs {
186            // To avoid relative dirs and permissions issues, we canonicalize the host path
187            let canonicalized = tokio::fs::canonicalize(&dir.host_path)
188                .await
189                .context("Error when canonicalizing given path")?;
190            // We need this later and will have to return an error anyway if this fails
191            let str_canonical = canonicalized.to_str().ok_or_else(|| anyhow::anyhow!("Canonicalized path cannot be converted to a string for use in a plugin. This is a limitation of the WASI API"))?.to_string();
192            // Check if the path is a file or a dir so we can handle permissions accordingly
193            let is_dir = tokio::fs::metadata(&canonicalized)
194                .await
195                .map(|m| m.is_dir())
196                .context("Error when checking if path is a file or a dir")?;
197            let (host_path, guest_path, dir_perms) = match (is_dir, dir.component_path) {
198                (true, Some(path)) => (canonicalized.clone(), path, DIRECTORY_ALLOW),
199                (false, Some(path)) => (
200                    canonicalized
201                        .parent()
202                        .ok_or_else(|| anyhow::anyhow!("Could not get parent of given file"))?
203                        .to_path_buf(),
204                    path,
205                    DIRECTORY_DENY,
206                ),
207                (true, None) => (
208                    canonicalized.clone(),
209                    str_canonical.clone(),
210                    DIRECTORY_ALLOW,
211                ),
212                (false, None) => {
213                    let parent = canonicalized
214                        .parent()
215                        .ok_or_else(|| anyhow::anyhow!("Could not get parent of given file"))?
216                        .to_path_buf();
217                    (
218                        parent.clone(),
219                        // SAFETY: We already checked that canonicalized was a string above so we
220                        // can just unwrap here
221                        parent.to_str().unwrap().to_string(),
222                        DIRECTORY_DENY,
223                    )
224                }
225            };
226
227            // On Windows, we need to normalize the path separators to "/" since that is what is
228            // expected by things like `PathBuf` when built for WASI.
229            #[cfg(target_family = "windows")]
230            let guest_path = guest_path.replace('\\', "/");
231            #[cfg(target_family = "windows")]
232            let str_canonical = str_canonical.replace('\\', "/");
233            ctx.preopened_dir(host_path, guest_path, dir_perms, FilePerms::all())
234                .context("Error when preopening path argument")?;
235            // Substitute the path in the args with the canonicalized path
236            let matching = args
237                .iter_mut()
238                .find(|arg| {
239                    <&mut std::string::String as std::convert::AsRef<Path>>::as_ref(arg)
240                        == dir.host_path
241                })
242                .ok_or_else(|| {
243                    anyhow::anyhow!(
244                        "Could not find host path {} in args for replacement",
245                        dir.host_path.display()
246                    )
247                })?;
248            *matching = str_canonical;
249        }
250        // Disable socket connections for now. We may gradually open this up later
251        ctx.socket_addr_check(|_, _| Box::pin(async { false }))
252            .inherit_stdio()
253            .preopened_dir(plugin_dir, "/", DIRECTORY_ALLOW, FilePerms::all())
254            .context("Error when preopening plugin dir")?
255            .args(&args)
256            .envs(&vars);
257
258        plugin.store.data_mut().ctx = ctx.build();
259        plugin
260            .instance
261            .wasi_cli_run()
262            .call_run(&mut plugin.store)
263            .await
264            .context("Error when running wasm component")?
265            .map_err(|_| anyhow::anyhow!("Error when running subcommand"))
266    }
267}