Skip to content
Draft
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
184 changes: 184 additions & 0 deletions crates/wash-runtime/src/engine/workload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1336,6 +1336,33 @@ impl UnresolvedWorkload {
.iter()
.flat_map(|(_, interfaces)| interfaces.clone())
.collect();

// Validate: if multiple named entries of the same namespace:package
// are matched to this plugin, the plugin must support named instances
let mut ns_pkg_named: HashMap<(&str, &str), Vec<&str>> = HashMap::new();
for iface in &plugin_matched_interfaces {
if let Some(name) = &iface.name {
ns_pkg_named
.entry((iface.namespace.as_str(), iface.package.as_str()))
.or_default()
.push(name.as_str());
}
}
for ((ns, pkg), mut names) in ns_pkg_named {
if names.len() > 1 && !p.supports_named_instances() {
names.sort_unstable();
bail!(
"plugin '{}' does not support named instances, but workload \
requires {} named entries for {ns}:{pkg} (names: {}). \
The plugin must implement supports_named_instances() to \
handle multiplexed interfaces.",
plugin_id,
names.len(),
names.join(", ")
);
}
}

debug!(
plugin_id = plugin_id,
interfaces = ?plugin_matched_interfaces,
Expand Down Expand Up @@ -1762,6 +1789,7 @@ mod tests {
on_workload_bind_count: Arc<AtomicUsize>,
on_workload_item_bind_count: Arc<AtomicUsize>,
on_workload_resolved_count: Arc<AtomicUsize>,
named_instance_support: bool,
}

impl MockPlugin {
Expand All @@ -1777,9 +1805,15 @@ mod tests {
on_workload_bind_count: Arc::new(AtomicUsize::new(0)),
on_workload_item_bind_count: Arc::new(AtomicUsize::new(0)),
on_workload_resolved_count: Arc::new(AtomicUsize::new(0)),
named_instance_support: false,
}
}

fn with_named_instance_support(mut self) -> Self {
self.named_instance_support = true;
self
}

/// Returns the number of times the specified method was called.
fn get_call_count(&self, method: &str) -> usize {
match method {
Expand All @@ -1806,6 +1840,10 @@ mod tests {
self.world.clone()
}

fn supports_named_instances(&self) -> bool {
self.named_instance_support
}

async fn on_workload_bind(
&self,
_workload: &UnresolvedWorkload,
Expand Down Expand Up @@ -1939,6 +1977,7 @@ mod tests {
interfaces: ["container".to_string()].into_iter().collect(),
version: Some(semver::Version::parse("0.2.0-draft").unwrap()),
config: std::collections::HashMap::new(),
name: None,
};

let plugin = Arc::new(MockPlugin::new(
Expand Down Expand Up @@ -2316,6 +2355,7 @@ mod tests {
interfaces: ["handler".to_string()].into_iter().collect(),
version: Some(semver::Version::parse("0.2.0").unwrap()),
config: std::collections::HashMap::new(),
name: None,
};

let messaging_consumer = WitInterface {
Expand All @@ -2326,6 +2366,7 @@ mod tests {
.collect(),
version: Some(semver::Version::parse("0.2.0").unwrap()),
config: std::collections::HashMap::new(),
name: None,
};

let logging = WitInterface {
Expand All @@ -2334,6 +2375,7 @@ mod tests {
interfaces: ["logging".to_string()].into_iter().collect(),
version: Some(semver::Version::parse("0.1.0-draft").unwrap()),
config: std::collections::HashMap::new(),
name: None,
};

let messaging_plugin = Arc::new(MockPlugin::new(
Expand Down Expand Up @@ -2376,6 +2418,7 @@ mod tests {
.collect(),
version: Some(semver::Version::parse("0.2.0").unwrap()),
config: std::collections::HashMap::new(),
name: None,
},
logging,
],
Expand Down Expand Up @@ -2407,6 +2450,7 @@ mod tests {
interfaces: ["logging".to_string()].into_iter().collect(),
version: Some(semver::Version::parse("0.1.0-draft").unwrap()),
config: std::collections::HashMap::new(),
name: None,
};

let plugin = Arc::new(MockPlugin::new(
Expand Down Expand Up @@ -2443,4 +2487,144 @@ mod tests {
let (_bound_plugin, component_ids) = &bound_plugins[0];
assert_eq!(component_ids.len(), 1);
}

fn keyvalue_interface(name: Option<&str>) -> WitInterface {
WitInterface {
namespace: "wasi".to_string(),
package: "keyvalue".to_string(),
interfaces: ["store".to_string()].into_iter().collect(),
version: Some(semver::Version::parse("0.2.0-draft").unwrap()),
config: std::collections::HashMap::new(),
name: name.map(String::from),
}
}

/// Two named `wasi:keyvalue` entries, plugin doesn't support naming -> error
#[tokio::test]
async fn test_named_interfaces_fail_without_plugin_support() {
let plugin = Arc::new(MockPlugin::new(
"keyvalue-plugin",
vec![],
vec![keyvalue_interface(None)],
));

let mut plugins = HashMap::new();
plugins.insert(plugin.id(), plugin.clone() as Arc<dyn HostPlugin>);

let mut workload = UnresolvedWorkload::new(
"test-workload-id".to_string(),
"test-workload".to_string(),
"test-namespace".to_string(),
None,
vec![create_test_component("component1")],
vec![
keyvalue_interface(Some("cache")),
keyvalue_interface(Some("sessions")),
],
);

let result = workload.bind_plugins(&plugins).await;
match result {
Ok(_) => panic!("Expected error for unsupported named instances"),
Err(e) => {
let err_msg = format!("{e}");
assert!(
err_msg.contains("does not support named instances"),
"Expected 'does not support named instances' error, got: {err_msg}"
);
}
}
}

/// Same setup but plugin returns `supports_named_instances() == true` -> succeeds
#[tokio::test]
async fn test_named_interfaces_succeed_with_plugin_support() {
let plugin = Arc::new(
MockPlugin::new("keyvalue-plugin", vec![], vec![keyvalue_interface(None)])
.with_named_instance_support(),
);

let mut plugins = HashMap::new();
plugins.insert(plugin.id(), plugin.clone() as Arc<dyn HostPlugin>);

let mut workload = UnresolvedWorkload::new(
"test-workload-id".to_string(),
"test-workload".to_string(),
"test-namespace".to_string(),
None,
vec![create_test_component("component1")],
vec![
keyvalue_interface(Some("cache")),
keyvalue_interface(Some("sessions")),
],
);

let result = workload.bind_plugins(&plugins).await;
if let Err(e) = result {
panic!("Expected success but got error: {e}");
}
}

/// Only one named entry -> no multiplexing needed, passes even without plugin support
#[tokio::test]
async fn test_single_named_interface_no_validation() {
let plugin = Arc::new(MockPlugin::new(
"keyvalue-plugin",
vec![],
vec![keyvalue_interface(None)],
));

let mut plugins = HashMap::new();
plugins.insert(plugin.id(), plugin.clone() as Arc<dyn HostPlugin>);

let mut workload = UnresolvedWorkload::new(
"test-workload-id".to_string(),
"test-workload".to_string(),
"test-namespace".to_string(),
None,
vec![create_test_component("component1")],
vec![keyvalue_interface(Some("cache"))],
);

let result = workload.bind_plugins(&plugins).await;
if let Err(e) = result {
panic!("Single named entry should not require named instance support: {e}");
}
}

/// Existing unnamed entries -> no change in behavior
#[tokio::test]
async fn test_unnamed_interfaces_backwards_compatible() {
let iface = WitInterface {
namespace: "wasi".to_string(),
package: "blobstore".to_string(),
interfaces: ["container".to_string()].into_iter().collect(),
version: Some(semver::Version::parse("0.2.0-draft").unwrap()),
config: std::collections::HashMap::new(),
name: None,
};

let plugin = Arc::new(MockPlugin::new(
"blobstore-plugin",
vec![],
vec![iface.clone()],
));

let mut plugins = HashMap::new();
plugins.insert(plugin.id(), plugin.clone() as Arc<dyn HostPlugin>);

let mut workload = UnresolvedWorkload::new(
"test-workload-id".to_string(),
"test-workload".to_string(),
"test-namespace".to_string(),
None,
vec![create_test_component("component1")],
vec![iface],
);

let result = workload.bind_plugins(&plugins).await;
if let Err(e) = result {
panic!("Unnamed interfaces should work as before: {e}");
}
}
}
2 changes: 2 additions & 0 deletions crates/wash-runtime/src/host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ impl Host {
interfaces: HashSet::from([interface]),
version,
config: HashMap::new(),
name: None,
})
};

Expand Down Expand Up @@ -295,6 +296,7 @@ impl Host {
]),
version: None,
config: HashMap::new(),
name: None,
});
}

Expand Down
15 changes: 15 additions & 0 deletions crates/wash-runtime/src/plugin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,21 @@ pub trait HostPlugin: std::any::Any + Send + Sync + 'static {
/// A `WitWorld` containing the plugin's imports and exports.
fn world(&self) -> WitWorld;

/// Returns whether this plugin supports handling multiple named instances
/// of the same namespace:package interface.
///
/// When a workload declares multiple host interfaces with the same
/// namespace:package but different names (e.g., two `wasi:keyvalue` entries
/// named "cache" and "sessions"), the plugin must be able to distinguish
/// and route to each named backend.
///
/// The default is `false`, which causes binding to fail if the workload
/// requires named multiplexing. Plugins that implement multiplexing
/// should override this to return `true`.
fn supports_named_instances(&self) -> bool {
false
}

/// Called when the plugin is started during host initialization.
///
/// This method allows plugins to perform any necessary setup before
Expand Down
6 changes: 6 additions & 0 deletions crates/wash-runtime/src/washlet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,11 @@ impl From<types::v2::WitInterface> for crate::wit::WitInterface {
},
interfaces: wi.interfaces.into_iter().collect(),
config: wi.config,
name: if wi.name.is_empty() {
None
} else {
Some(wi.name)
},
}
}
}
Expand Down Expand Up @@ -585,6 +590,7 @@ impl From<crate::wit::WitInterface> for types::v2::WitInterface {
version: wi.version.map(|v| v.to_string()).unwrap_or_default(),
interfaces: wi.interfaces.into_iter().collect(),
config: wi.config,
name: wi.name.unwrap_or_default(),
}
}
}
Expand Down
Loading
Loading