wash_lib/plugin/
subcommand.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
mod bindings {
    wasmtime::component::bindgen!({
        world: "subcommands",
        async: true,
    });
}

pub use bindings::exports::wasmcloud::wash::subcommand::Metadata;
use bindings::Subcommands;

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Ok};
use wasmtime::component::{Component, Linker};
use wasmtime::{Config, Engine};
use wasmtime_wasi::{DirPerms, FilePerms, WasiCtxBuilder};
use wasmtime_wasi_http::WasiHttpCtx;

use super::Data;

const DIRECTORY_ALLOW: DirPerms = DirPerms::all();
const DIRECTORY_DENY: DirPerms = DirPerms::READ;

struct InstanceData {
    instance: Subcommands,
    metadata: Metadata,
    loaded_path: PathBuf,
    store: wasmtime::Store<Data>,
}

/// A struct that manages loading and running subcommand plugins
pub struct SubcommandRunner {
    engine: Engine,
    plugins: HashMap<String, InstanceData>,
}

/// Host directory mapping to provide to plugins
pub struct DirMapping {
    /// The path on the host that should be opened. If this is a file, its parent directory will be
    /// added with no RW access, but with RW access to the files in that directory. If it is a
    /// directory, it will be added with RW access to that directory
    pub host_path: PathBuf,
    /// The path that will be accessible in the component. Otherwise defaults to the `host_path`
    pub component_path: Option<String>,
}

impl SubcommandRunner {
    /// Creates a new subcommand runner with no plugins loaded.
    pub fn new() -> anyhow::Result<Self> {
        let mut config = Config::new();
        // Attempt to use caching, but only warn if it fails
        if let Err(e) = config.cache_config_load_default() {
            tracing::warn!(err = ?e, "Failed to load wasm cache");
        }
        config.wasm_component_model(true);
        config.async_support(true);
        let engine = Engine::new(&config)?;
        Ok(Self {
            engine,
            plugins: HashMap::new(),
        })
    }

    /// Create a new runner initialized with the list of plugins provided.
    ///
    /// This function will fail if any of the plugins fail to load. If you want to gracefully handle
    /// errors, use [`add_plugin`](Self::add_plugin) instead.
    pub async fn new_with_plugins(
        plugins: impl IntoIterator<Item = impl AsRef<Path>>,
    ) -> anyhow::Result<Self> {
        let mut runner = Self::new()?;
        for plugin in plugins {
            runner.add_plugin(plugin).await?;
        }
        Ok(runner)
    }

    /// Adds a plugin to the runner, returning the metadata for the plugin and otherwise returning
    /// an error if there was a problem loading the plugin. This can happen due to bad instantiation
    /// or if a plugin with the same ID has already been loaded. As such, errors from this function
    /// should be treated as a warning as execution can continue
    pub async fn add_plugin(&mut self, path: impl AsRef<Path>) -> anyhow::Result<Metadata> {
        self.add_plugin_internal(path, false).await
    }

    /// Same as [`add_plugin`](Self::add_plugin), but will not return an error if the plugin exists,
    /// instead updating the metadata for the plugin. This is an upsert operation and will register
    /// the plugin if it does not exist.
    pub async fn update_plugin(&mut self, path: impl AsRef<Path>) -> anyhow::Result<Metadata> {
        self.add_plugin_internal(path, true).await
    }

    async fn add_plugin_internal(
        &mut self,
        path: impl AsRef<Path>,
        update: bool,
    ) -> anyhow::Result<Metadata> {
        // We create a bare context here for registration and then update the store with a new context before running
        let ctx = WasiCtxBuilder::new().build();

        let ctx = Data {
            table: wasmtime::component::ResourceTable::default(),
            ctx,
            http: WasiHttpCtx::new(),
        };

        let mut store = wasmtime::Store::new(&self.engine, ctx);

        let component = Component::from_file(&self.engine, &path)?;
        let mut linker = Linker::new(&self.engine);
        wasmtime_wasi::add_to_linker_async(&mut linker)
            .context("failed to link core WASI interfaces")?;
        wasmtime_wasi_http::add_only_http_to_linker_async(&mut linker)
            .context("failed to link `wasi:http`")?;

        let instance = Subcommands::instantiate_async(&mut store, &component, &linker).await?;
        let metadata = instance
            .wasmcloud_wash_subcommand()
            .call_register(&mut store)
            .await?;
        let maybe_existing = self.plugins.insert(
            metadata.id.clone(),
            InstanceData {
                instance,
                metadata: metadata.clone(),
                loaded_path: path.as_ref().to_owned(),
                store,
            },
        );

        match (update, maybe_existing) {
            // If we're updating and the plugin exists already, overwrite is ok.
            (true, _) | (false, None) => Ok(metadata),
            // If update isn't set, then we don't allow the update
            (false, Some(plugin)) => {
                // Insert the existing plugin back into the map
                let id = plugin.metadata.id.clone();
                self.plugins.insert(plugin.metadata.id.clone(), plugin);
                Err(anyhow::anyhow!("Plugin with id {id} already exists"))
            }
        }
    }

    /// Get the metadata for a plugin with the given ID if it exists.
    pub fn metadata(&self, id: &str) -> Option<&Metadata> {
        self.plugins.get(id).map(|p| &p.metadata)
    }

    /// Returns a list of all metadata for all plugins.
    pub fn all_metadata(&self) -> Vec<&Metadata> {
        self.plugins.values().map(|data| &data.metadata).collect()
    }

    /// Returns the path to the plugin with the given ID.
    pub fn path(&self, id: &str) -> Option<&Path> {
        self.plugins.get(id).map(|p| p.loaded_path.as_path())
    }

    /// Run a subcommand with the given name and args. The plugin will inherit all
    /// stdout/stderr/stdin/env. The given plugin_dirs will be mapped into the plugin after
    /// canonicalizing all paths and normalizing them to use `/` instead of `\`. An error will only
    /// be returned if there was a problem with the plugin (such as the plugin dirs not existing or
    /// failure to canonicalize) or the subcommand itself.
    ///
    /// All plugins will be passed environment variables starting with
    /// `WASH_PLUGIN_${plugin_id.to_upper()}_` from the current process. Other vars will be ignored
    pub async fn run(
        &mut self,
        plugin_id: &str,
        plugin_dir: PathBuf,
        dirs: Vec<DirMapping>,
        mut args: Vec<String>,
    ) -> anyhow::Result<()> {
        let plugin = self
            .plugins
            .get_mut(plugin_id)
            .ok_or_else(|| anyhow::anyhow!("Plugin with id {plugin_id} does not exist"))?;

        let env_prefix = format!("WASH_PLUGIN_{}_", plugin_id.to_uppercase());
        let vars: Vec<_> = std::env::vars()
            .filter(|(k, _)| k.starts_with(&env_prefix))
            .collect();
        let mut ctx = WasiCtxBuilder::new();
        for dir in dirs {
            // To avoid relative dirs and permissions issues, we canonicalize the host path
            let canonicalized = tokio::fs::canonicalize(&dir.host_path)
                .await
                .context("Error when canonicalizing given path")?;
            // We need this later and will have to return an error anyway if this fails
            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();
            // Check if the path is a file or a dir so we can handle permissions accordingly
            let is_dir = tokio::fs::metadata(&canonicalized)
                .await
                .map(|m| m.is_dir())
                .context("Error when checking if path is a file or a dir")?;
            let (host_path, guest_path, dir_perms) = match (is_dir, dir.component_path) {
                (true, Some(path)) => (canonicalized.clone(), path, DIRECTORY_ALLOW),
                (false, Some(path)) => (
                    canonicalized
                        .parent()
                        .ok_or_else(|| anyhow::anyhow!("Could not get parent of given file"))?
                        .to_path_buf(),
                    path,
                    DIRECTORY_DENY,
                ),
                (true, None) => (
                    canonicalized.clone(),
                    str_canonical.clone(),
                    DIRECTORY_ALLOW,
                ),
                (false, None) => {
                    let parent = canonicalized
                        .parent()
                        .ok_or_else(|| anyhow::anyhow!("Could not get parent of given file"))?
                        .to_path_buf();
                    (
                        parent.clone(),
                        // SAFETY: We already checked that canonicalized was a string above so we
                        // can just unwrap here
                        parent.to_str().unwrap().to_string(),
                        DIRECTORY_DENY,
                    )
                }
            };

            // On Windows, we need to normalize the path separators to "/" since that is what is
            // expected by things like `PathBuf` when built for WASI.
            #[cfg(target_family = "windows")]
            let guest_path = guest_path.replace('\\', "/");
            #[cfg(target_family = "windows")]
            let str_canonical = str_canonical.replace('\\', "/");
            ctx.preopened_dir(host_path, guest_path, dir_perms, FilePerms::all())
                .context("Error when preopening path argument")?;
            // Substitute the path in the args with the canonicalized path
            let matching = args
                .iter_mut()
                .find(|arg| {
                    <&mut std::string::String as std::convert::AsRef<Path>>::as_ref(arg)
                        == dir.host_path
                })
                .ok_or_else(|| {
                    anyhow::anyhow!(
                        "Could not find host path {} in args for replacement",
                        dir.host_path.display()
                    )
                })?;
            *matching = str_canonical;
        }
        // Disable socket connections for now. We may gradually open this up later
        ctx.socket_addr_check(|_, _| Box::pin(async { false }))
            .inherit_stdio()
            .preopened_dir(plugin_dir, "/", DIRECTORY_ALLOW, FilePerms::all())
            .context("Error when preopening plugin dir")?
            .args(&args)
            .envs(&vars);

        plugin.store.data_mut().ctx = ctx.build();
        plugin
            .instance
            .wasi_cli_run()
            .call_run(&mut plugin.store)
            .await
            .context("Error when running wasm component")?
            .map_err(|_| anyhow::anyhow!("Error when running subcommand"))
    }
}