feat(runtime): host service-scoped KV capability
Introduce an injectable, deadline-aware KvBackend boundary and link only the exact wasmeld:kv/store@0.1.0 import requested by a Component. Namespace entries by service ID so revisions share state while services remain isolated, reject registrations without a backend, and enforce 256-byte keys and 64 KiB values. Cover capability discovery, cross-revision sharing, service isolation, missing backends, and resource limits with Component integration tests.
This commit is contained in:
@@ -1,13 +1,17 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::OnceLock,
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use wasmeld_runtime::{ResourceLimits, Runtime, RuntimeConfig, RuntimeError, ServiceManifest};
|
||||
use wasmeld_runtime::{
|
||||
KV_MAX_VALUE_BYTES, KvBackend, KvBackendError, ResourceLimits, Runtime, RuntimeConfig,
|
||||
RuntimeError, ServiceManifest,
|
||||
};
|
||||
|
||||
static COMPONENTS_BUILT: OnceLock<()> = OnceLock::new();
|
||||
|
||||
@@ -188,6 +192,97 @@ fn explicit_clock_capability_is_linked() {
|
||||
runtime.stop(&key).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kv_capability_is_service_scoped_and_shared_across_revisions() {
|
||||
let backend = Arc::new(MemoryKvBackend::default());
|
||||
let runtime = Runtime::new(RuntimeConfig {
|
||||
kv_backend: Some(backend),
|
||||
..RuntimeConfig::default()
|
||||
})
|
||||
.expect("runtime should start");
|
||||
let first_key = register_component_revision(
|
||||
&runtime,
|
||||
"shared-kv",
|
||||
"0.1.0",
|
||||
"kv_probe_component.wasm",
|
||||
test_limits(),
|
||||
);
|
||||
let second_key = register_component_revision(
|
||||
&runtime,
|
||||
"shared-kv",
|
||||
"0.2.0",
|
||||
"kv_probe_component.wasm",
|
||||
test_limits(),
|
||||
);
|
||||
let isolated_key = register_component_revision(
|
||||
&runtime,
|
||||
"isolated-kv",
|
||||
"0.1.0",
|
||||
"kv_probe_component.wasm",
|
||||
test_limits(),
|
||||
);
|
||||
|
||||
let first = runtime.start(&first_key, Vec::new()).unwrap();
|
||||
let second = runtime.start(&second_key, Vec::new()).unwrap();
|
||||
let isolated = runtime.start(&isolated_key, Vec::new()).unwrap();
|
||||
|
||||
assert_eq!(first.invoke(b"set:answer:forty-two".to_vec()).unwrap(), b"");
|
||||
assert_eq!(second.invoke(b"get:answer".to_vec()).unwrap(), b"forty-two");
|
||||
assert_eq!(isolated.invoke(b"get:answer".to_vec()).unwrap(), b"");
|
||||
assert_eq!(
|
||||
runtime.capabilities(&first_key).unwrap()[0].interface(),
|
||||
"wasmeld:kv/store@0.1.0"
|
||||
);
|
||||
|
||||
runtime.stop_all().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kv_capability_requires_a_backend_and_enforces_limits() {
|
||||
let artifact = component_artifact("kv_probe_component.wasm");
|
||||
let manifest = ServiceManifest {
|
||||
id: "kv-without-backend".to_owned(),
|
||||
revision: "0.1.0".to_owned(),
|
||||
component: artifact.display().to_string(),
|
||||
world: component_world("kv_probe_component.wasm").to_owned(),
|
||||
limits: test_limits(),
|
||||
};
|
||||
let bytes = fs::read(&artifact).unwrap();
|
||||
let runtime = Runtime::new(RuntimeConfig::default()).unwrap();
|
||||
assert!(matches!(
|
||||
runtime.register(manifest, &bytes),
|
||||
Err(RuntimeError::CapabilityUnavailable(interface))
|
||||
if interface == "wasmeld:kv/store@0.1.0"
|
||||
));
|
||||
|
||||
let runtime = Runtime::new(RuntimeConfig {
|
||||
kv_backend: Some(Arc::new(MemoryKvBackend::default())),
|
||||
..RuntimeConfig::default()
|
||||
})
|
||||
.unwrap();
|
||||
let mut limits = test_limits();
|
||||
limits.max_input_bytes = 128 * 1024;
|
||||
let key = register_component(&runtime, "kv-limits", "kv_probe_component.wasm", limits);
|
||||
let actor = runtime.start(&key, Vec::new()).unwrap();
|
||||
|
||||
let long_key = format!("get:{}", "k".repeat(257));
|
||||
assert!(matches!(
|
||||
actor.invoke(long_key.into_bytes()),
|
||||
Err(RuntimeError::ComponentError { message, .. })
|
||||
if message.contains("InvalidKey")
|
||||
));
|
||||
|
||||
let mut oversized = b"set:key:".to_vec();
|
||||
oversized.resize(oversized.len() + KV_MAX_VALUE_BYTES + 1, b'x');
|
||||
assert!(matches!(
|
||||
actor.invoke(oversized),
|
||||
Err(RuntimeError::ComponentError { message, .. })
|
||||
if message.contains("ValueTooLarge")
|
||||
));
|
||||
|
||||
runtime.stop(&key).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_rejects_non_whitelisted_wasi_imports() {
|
||||
let runtime = Runtime::new(RuntimeConfig::default()).expect("runtime should start");
|
||||
@@ -225,11 +320,21 @@ fn register_component(
|
||||
service_id: &str,
|
||||
artifact_name: &str,
|
||||
limits: ResourceLimits,
|
||||
) -> wasmeld_runtime::ServiceKey {
|
||||
register_component_revision(runtime, service_id, "0.1.0", artifact_name, limits)
|
||||
}
|
||||
|
||||
fn register_component_revision(
|
||||
runtime: &Runtime,
|
||||
service_id: &str,
|
||||
revision: &str,
|
||||
artifact_name: &str,
|
||||
limits: ResourceLimits,
|
||||
) -> wasmeld_runtime::ServiceKey {
|
||||
let artifact = component_artifact(artifact_name);
|
||||
let manifest = ServiceManifest {
|
||||
id: service_id.to_owned(),
|
||||
revision: "0.1.0".to_owned(),
|
||||
revision: revision.to_owned(),
|
||||
component: artifact.display().to_string(),
|
||||
world: component_world(artifact_name).to_owned(),
|
||||
limits,
|
||||
@@ -256,6 +361,7 @@ fn build_components_once() {
|
||||
"components/fault/Cargo.toml",
|
||||
"components/spin/Cargo.toml",
|
||||
"components/clock-probe/Cargo.toml",
|
||||
"components/kv-probe/Cargo.toml",
|
||||
"components/wasi-clock-probe/Cargo.toml",
|
||||
] {
|
||||
let status = Command::new("rustup")
|
||||
@@ -323,9 +429,58 @@ fn component_world(artifact_name: &str) -> &'static str {
|
||||
"fault_component.wasm" => "component:fault/fault-component@0.1.0",
|
||||
"spin_component.wasm" => "component:spin/spin-component@0.1.0",
|
||||
"clock_probe_component.wasm" => "component:clock-probe/clock-probe-component@0.1.0",
|
||||
"kv_probe_component.wasm" => "component:kv-probe/kv-probe-component@0.1.0",
|
||||
"wasi_clock_probe_component.wasm" => {
|
||||
"component:wasi-clock-probe/wasi-clock-probe-component@0.1.0"
|
||||
}
|
||||
other => panic!("no WIT world registered for {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct MemoryKvBackend {
|
||||
entries: Mutex<HashMap<(String, String), Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl KvBackend for MemoryKvBackend {
|
||||
fn get(
|
||||
&self,
|
||||
service_id: &str,
|
||||
key: &str,
|
||||
_timeout: Duration,
|
||||
) -> Result<Option<Vec<u8>>, KvBackendError> {
|
||||
Ok(self
|
||||
.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&(service_id.to_owned(), key.to_owned()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
fn set(
|
||||
&self,
|
||||
service_id: &str,
|
||||
key: &str,
|
||||
value: &[u8],
|
||||
_timeout: Duration,
|
||||
) -> Result<(), KvBackendError> {
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((service_id.to_owned(), key.to_owned()), value.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(
|
||||
&self,
|
||||
service_id: &str,
|
||||
key: &str,
|
||||
_timeout: Duration,
|
||||
) -> Result<(), KvBackendError> {
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(service_id.to_owned(), key.to_owned()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user