Skip to content

Commit a5474c8

Browse files
committed
Add spin-conformance crate (#724)
This crate provides a suite of tests to determine which features of Spin a guest module is capable of exercising. The suite may be run via the `test` function, which accepts a `wasmtime::Module` and returns a `Report` which details which features the module does or does not support. Alternatively, there is also a CLI version which generates a report in JSON form. See the doc comments for `Report` and friends for details on what is tested. Note that the current implementation is quite simple, and there are a few ways a module implementation could "cheat", i.e. hard-code output to match what the test expects without actually doing the thing it was asked to do. We could prevent that by randomly generating parameters, but I don't consider that a high priority. Signed-off-by: Joel Dice <joel.dice@fermyon.com>
1 parent 85761ec commit a5474c8

File tree

12 files changed

+1156
-0
lines changed

12 files changed

+1156
-0
lines changed

Cargo.lock

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ e2e-tests = []
7070

7171
[workspace]
7272
members = [
73+
"crates/abi-conformance",
7374
"crates/build",
7475
"crates/config",
7576
"crates/engine",

crates/abi-conformance/Cargo.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[package]
2+
name = "spin-abi-conformance"
3+
version = "0.5.0"
4+
edition = "2021"
5+
authors = [ "Fermyon Engineering <engineering@fermyon.com>" ]
6+
7+
[dependencies]
8+
anyhow = "1.0.44"
9+
clap = { version = "3.1.15", features = ["derive", "env"] }
10+
serde = { version = "1.0", features = [ "derive" ] }
11+
serde_json = "1.0.82"
12+
wit-bindgen-wasmtime = { git = "https://github.com/bytecodealliance/wit-bindgen", rev = "cb871cfa1ee460b51eb1d144b175b9aab9c50aba" }
13+
wasmtime = "0.39.1"
14+
wasmtime-wasi = "0.39.1"
15+
wasi-common = "0.39.1"
16+
rand = "0.8.5"
17+
rand_chacha = "0.3.1"
18+
rand_core = "0.6.3"
19+
tempfile = "3.3.0"
20+
cap-std = "0.25.2"
21+
toml = "0.5.9"
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
use anyhow::Result;
2+
use clap::Parser;
3+
use std::{
4+
fs::{self, File},
5+
io::{self, Read, Write},
6+
path::PathBuf,
7+
};
8+
use wasmtime::{Config, Engine, Module};
9+
10+
#[derive(Parser)]
11+
#[clap(author, version, about)]
12+
pub struct Options {
13+
/// Name of Wasm file to test (or stdin if not specified)
14+
#[clap(short, long)]
15+
pub input: Option<PathBuf>,
16+
17+
/// Name of JSON file to write report to (or stdout if not specified)
18+
#[clap(short, long)]
19+
pub output: Option<PathBuf>,
20+
21+
/// Name of TOML configuration file to use
22+
#[clap(short, long)]
23+
pub config: Option<PathBuf>,
24+
}
25+
26+
fn main() -> Result<()> {
27+
let options = &Options::parse();
28+
29+
let engine = &Engine::new(&Config::new())?;
30+
31+
let module = &if let Some(input) = &options.input {
32+
Module::from_file(engine, input)
33+
} else {
34+
Module::new(engine, &{
35+
let mut buffer = Vec::new();
36+
io::stdin().read_to_end(&mut buffer)?;
37+
buffer
38+
})
39+
}?;
40+
41+
let config = if let Some(config) = &options.config {
42+
toml::from_str(&fs::read_to_string(config)?)?
43+
} else {
44+
spin_abi_conformance::Config::default()
45+
};
46+
47+
let report = &spin_abi_conformance::test(module, config)?;
48+
49+
let writer = if let Some(output) = &options.output {
50+
Box::new(File::create(output)?) as Box<dyn Write>
51+
} else {
52+
Box::new(io::stdout().lock())
53+
};
54+
55+
serde_json::to_writer_pretty(writer, report)?;
56+
57+
Ok(())
58+
}

crates/abi-conformance/src/lib.rs

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
//! Spin ABI Conformance Test Suite
2+
//!
3+
//! This crate provides a suite of tests to check a given SDK or language integration's implementation of Spin
4+
//! functions. It is intended for use by language integrators and SDK authors to verify that their integrations
5+
//! and SDKs work correctly with the Spin ABIs. It is not intended for Spin _application_ development, since it
6+
//! requires a module written specifically to behave as expected by this suite, whereas a given application will
7+
//! have its own expected behaviors which can only be verified by tests specific to that application.
8+
//!
9+
//! The suite may be run via the [`test()`] function, which accepts a [`wasmtime::Module`] and a [`Config`] and
10+
//! returns a [`Report`] which details which tests succeeded and which failed. The definition of success in this
11+
//! context depends on whether the test is for a function implemented by the guest (e.g. triggers) or by the host
12+
//! (e.g. outbound requests).
13+
//!
14+
//! - For a guest-implemented function, the host will call the function and assert the result matches what is
15+
//! expected (see [`Report::http_trigger`] for an example).
16+
//!
17+
//! - For a host-implemented function, the host will call a guest-implemented function according to the specified
18+
//! [`InvocationStyle`] with a set of arguments indicating which host function to call and with what arguments.
19+
//! The host then asserts that host function was indeed called with the expected arguments (see
20+
//! [`Report::outbound_http`] for an example).
21+
22+
use anyhow::{Context as _, Result};
23+
use outbound_http::OutboundHttp;
24+
use outbound_pg::OutboundPg;
25+
use outbound_redis::OutboundRedis;
26+
use serde::{Deserialize, Serialize};
27+
use spin_config::SpinConfig;
28+
use spin_http::{Method, Request, SpinHttp, SpinHttpData};
29+
use spin_redis::SpinRedisData;
30+
use std::str;
31+
use wasi_common::{pipe::WritePipe, WasiCtx};
32+
use wasmtime::{InstancePre, Linker, Module, Store};
33+
use wasmtime_wasi::WasiCtxBuilder;
34+
35+
pub use outbound_pg::PgReport;
36+
pub use outbound_redis::RedisReport;
37+
pub use wasi::WasiReport;
38+
39+
mod outbound_http;
40+
mod outbound_pg;
41+
mod outbound_redis;
42+
mod spin_config;
43+
mod spin_http;
44+
mod spin_redis;
45+
mod wasi;
46+
47+
/// The invocation style to use when the host asks the guest to call a host-implemented function
48+
#[derive(Copy, Clone, Default, Deserialize)]
49+
pub enum InvocationStyle {
50+
/// The host should call into the guest using WASI's `_start` function, passing arguments as CLI parameters.
51+
///
52+
/// This is the default if no value is specified.
53+
#[default]
54+
Command,
55+
56+
/// The host should call into the guest using spin-http.wit's `handle-http-request` function, passing arguments
57+
/// via the request body as a JSON array of strings.
58+
HttpTrigger,
59+
}
60+
61+
/// Configuration options for the [`test()`] function
62+
#[derive(Default, Deserialize)]
63+
pub struct Config {
64+
/// The invocation style to use when the host asks the guest to call a host-implemented function
65+
#[serde(default)]
66+
pub invocation_style: InvocationStyle,
67+
}
68+
69+
/// Report of which tests succeeded or failed
70+
///
71+
/// These results fall into either of two categories:
72+
///
73+
/// - Guest-implemented exports which behave as prescribed by the test (e.g. `http_trigger` and `redis_trigger`)
74+
///
75+
/// - Host-implemented imports which are called by the guest with the arguments specified by the host
76+
/// (e.g. `outbound_http`)
77+
#[derive(Serialize)]
78+
pub struct Report {
79+
/// Result of the Spin HTTP trigger test
80+
///
81+
/// The guest module should expect a call to `handle-http-request` with a POST request to "/foo" containing a
82+
/// single header "foo: bar" and a UTF-8 string body "Hello, SpinHttp!" and return a 200 OK response that
83+
/// includes a single header "lorem: ipsum" and a UTF-8 string body "dolor sit amet".
84+
pub http_trigger: Result<(), String>,
85+
86+
/// Result of the Spin Redis trigger test
87+
///
88+
/// The guest module should expect a call to `handle-redis-message` with the text "Hello, SpinRedis!" and
89+
/// return `ok(unit)` as the result.
90+
pub redis_trigger: Result<(), String>,
91+
92+
/// Result of the Spin config test
93+
///
94+
/// The guest module should expect a call according to [`InvocationStyle`] with \["config", "foo"\] as
95+
/// arguments. The module should call the host-implemented `spin-config::get-config` function with "foo" as
96+
/// the argument and expect `ok("bar")` as the result. The host will assert that said function is called
97+
/// exactly once with the expected argument.
98+
pub config: Result<(), String>,
99+
100+
/// Result of the Spin outbound HTTP test
101+
///
102+
/// The guest module should expect a call according to [`InvocationStyle`] with \["outbound-http",
103+
/// "http://127.0.0.1/test"\] as arguments. The module should call the host-implemented
104+
/// `wasi-outbound-http::request` function with a GET request for the URL "http://127.0.0.1/test" with no
105+
/// headers, params, or body, and expect `ok({ status: 200, headers: none, body: some("Jabberwocky"))` as the
106+
/// result. The host will assert that said function is called exactly once with the specified argument.
107+
pub outbound_http: Result<(), String>,
108+
109+
/// Results of the Spin outbound Redis tests
110+
///
111+
/// See [`RedisReport`] for details.
112+
pub outbound_redis: RedisReport,
113+
114+
/// Results of the Spin outbound PostgreSQL tests
115+
///
116+
/// See [`PgReport`] for details.
117+
pub outbound_pg: PgReport,
118+
119+
/// Results of the WASI tests
120+
///
121+
/// See [`WasiReport`] for details.
122+
pub wasi: WasiReport,
123+
}
124+
125+
/// Run a test for each Spin-related function the specified `module` imports or exports, returning the results as a
126+
/// [`Report`].
127+
///
128+
/// See the fields of [`Report`] and the structs from which it is composed for descriptions of each test.
129+
pub fn test(module: &Module, config: Config) -> Result<Report> {
130+
let mut store = Store::new(
131+
module.engine(),
132+
Context {
133+
config,
134+
wasi: WasiCtxBuilder::new().arg("<wasm module>")?.build(),
135+
outbound_http: OutboundHttp::default(),
136+
outbound_redis: OutboundRedis::default(),
137+
outbound_pg: OutboundPg::default(),
138+
spin_http: SpinHttpData {},
139+
spin_redis: SpinRedisData {},
140+
spin_config: SpinConfig::default(),
141+
},
142+
);
143+
144+
let mut linker = Linker::<Context>::new(module.engine());
145+
wasmtime_wasi::add_to_linker(&mut linker, |context| &mut context.wasi)?;
146+
outbound_http::add_to_linker(&mut linker, |context| &mut context.outbound_http)?;
147+
outbound_redis::add_to_linker(&mut linker, |context| &mut context.outbound_redis)?;
148+
outbound_pg::add_to_linker(&mut linker, |context| &mut context.outbound_pg)?;
149+
spin_config::add_to_linker(&mut linker, |context| &mut context.spin_config)?;
150+
151+
let pre = linker.instantiate_pre(&mut store, module)?;
152+
153+
Ok(Report {
154+
http_trigger: spin_http::test(&mut store, &pre),
155+
156+
redis_trigger: spin_redis::test(&mut store, &pre),
157+
158+
config: spin_config::test(&mut store, &pre),
159+
160+
outbound_http: outbound_http::test(&mut store, &pre),
161+
162+
outbound_redis: outbound_redis::test(&mut store, &pre)?,
163+
164+
outbound_pg: outbound_pg::test(&mut store, &pre)?,
165+
166+
wasi: wasi::test(&mut store, &pre)?,
167+
})
168+
}
169+
170+
struct Context {
171+
config: Config,
172+
wasi: WasiCtx,
173+
outbound_http: OutboundHttp,
174+
outbound_redis: OutboundRedis,
175+
outbound_pg: OutboundPg,
176+
spin_http: SpinHttpData,
177+
spin_redis: SpinRedisData,
178+
spin_config: SpinConfig,
179+
}
180+
181+
fn run(fun: impl FnOnce() -> Result<()>) -> Result<(), String> {
182+
fun().map_err(|e| format!("{e:?}"))
183+
}
184+
185+
fn run_command(
186+
store: &mut Store<Context>,
187+
pre: &InstancePre<Context>,
188+
arguments: &[&str],
189+
fun: impl FnOnce(&mut Store<Context>) -> Result<()>,
190+
) -> Result<(), String> {
191+
run(|| {
192+
let stderr = WritePipe::new_in_memory();
193+
store.data_mut().wasi.set_stderr(Box::new(stderr.clone()));
194+
195+
let instance = &pre.instantiate(&mut *store)?;
196+
197+
let result = match store.data().config.invocation_style {
198+
InvocationStyle::HttpTrigger => {
199+
let handle =
200+
SpinHttp::new(&mut *store, instance, |context| &mut context.spin_http)?;
201+
202+
handle
203+
.handle_http_request(
204+
&mut *store,
205+
Request {
206+
method: Method::Post,
207+
uri: "/",
208+
headers: &[],
209+
params: &[],
210+
body: Some(&serde_json::to_vec(arguments)?),
211+
},
212+
)
213+
.map(drop) // Ignore the response and make this a `Result<(), Trap>` to match the `_start` case
214+
// below
215+
}
216+
217+
InvocationStyle::Command => {
218+
for argument in arguments {
219+
store.data_mut().wasi.push_arg(argument)?;
220+
}
221+
222+
instance
223+
.get_typed_func::<(), (), _>(&mut *store, "_start")?
224+
.call(&mut *store, ())
225+
}
226+
};
227+
228+
// Reset `Context::wasi` so the next test has a clean slate and also to ensure there are no more references
229+
// to the `stderr` pipe, ensuring `try_into_inner` succeeds below. This is also needed in case the caller
230+
// attached its own pipes for e.g. stdin and/or stdout and expects exclusive ownership once we return.
231+
store.data_mut().wasi = WasiCtxBuilder::new().arg("<wasm module>")?.build();
232+
233+
result.with_context(|| {
234+
String::from_utf8_lossy(&stderr.try_into_inner().unwrap().into_inner()).into_owned()
235+
})?;
236+
237+
fun(store)
238+
})
239+
}

0 commit comments

Comments
 (0)