Skip to content

Commit 9df2e1b

Browse files
authored
Merge pull request #2180 from fermyon/services
Runtime tests services
2 parents 412b51f + 0f4a33a commit 9df2e1b

File tree

16 files changed

+182
-27
lines changed

16 files changed

+182
-27
lines changed

Cargo.lock

Lines changed: 11 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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ members = [
124124
anyhow = "1.0.75"
125125
http-body-util = "=0.1.0-rc.2"
126126
hyper = { version = "=1.0.0-rc.3", features = ["full"] }
127-
reqwest = { version = "0.11", features = ["stream"] }
127+
reqwest = { version = "0.11", features = ["stream", "blocking"] }
128128
tracing = { version = "0.1", features = ["log"] }
129129

130130
wasi-common-preview1 = { version = "15.0.0", package = "wasi-common" }

tests/runtime-tests/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.lock

tests/runtime-tests/Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ edition = "2021"
55

66
[dependencies]
77
anyhow = { workspace = true }
8-
test-components = { path = "../test-components" }
98
env_logger = "0.10.0"
9+
fslock = "0.2.1"
1010
log = "0.4"
1111
nix = "0.26.1"
1212
reqwest = { workspace = true }
13-
toml = "0.8.6"
1413
temp-dir = "0.1.11"
14+
test-components = { path = "../test-components" }
15+
toml = "0.8.6"

tests/runtime-tests/README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,35 @@ Runtime tests are not full end-to-end integration tests, and thus there are some
1818

1919
## How do I run the tests?
2020

21-
The runtime tests can either be run as a library function (e.g., this is how they are run as part of Spin's test suite using `cargo test`) or they can be run stand alone using the `runtime-tests` crate's binary (i.e., running `cargo run` from this directory).
21+
The runtime tests can either be run as a library function (e.g., this is how they are run as part of Spin's test suite using `cargo test`), or they can be run stand alone using the `runtime-tests` crate's binary (i.e., running `cargo run` from this directory).
2222

2323
## How do I add a new test?
2424

2525
To add a new test you must add a new folder to the `tests` directory with at least a `spin.toml` manifest.
2626

2727
The manifest can reference pre-built Spin compliant WebAssembly modules that can be found in the `test-components` folder in the Spin repo. It does so by using the `{{$NAME}}` where `$NAME` is substituted for the name of the test component to be used. For example `{{sqlite}}` will use the test-component named "sqlite" found in the `test-components` directory.
2828

29-
The test directory may additionally contain an `error.txt` if the Spin application is expected to fail.
29+
The test directory may additionally contain:
30+
* an `error.txt` if the Spin application is expected to fail
31+
* a `services` config file (more on this below)
3032

3133
### The testing protocol
3234

3335
The test runner will make a GET request against the `/` path. The component should either return a 200 if everything goes well or a 500 if there is an error. If an `error.txt` file is present, the Spin application must return a 500 with the body set to some error message that contains the contents of `error.txt`.
3436

37+
### Services
38+
39+
Services allow for tests to be run against external sources. The service definitions can be found in the 'services' directory. Each test directory contains a 'services' file that configures the tests services. Each line of the services file should contain the name of a services file that needs to run. For example, the following 'services' file will run the `tcp-echo.py` service:
40+
41+
```txt
42+
tcp-echo.py
43+
```
44+
45+
Each service is run under a file lock meaning that all other tests that require that service must wait until the current test using that service has finished.
46+
47+
The following service types are supported:
48+
* Python services (a python script ending in the .py file extension)
49+
3550
## When do tests pass?
3651

3752
A test will pass in the following conditions:
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import socket
2+
import threading
3+
import os
4+
5+
def handle_client(client_socket):
6+
while True:
7+
data = client_socket.recv(1024)
8+
if not data:
9+
break
10+
# Echo the received data back to the client
11+
client_socket.send(data)
12+
client_socket.close()
13+
14+
def echo_server():
15+
host = "127.0.0.1"
16+
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
17+
server_socket.bind((host, 6001))
18+
server_socket.listen(5)
19+
_, port = server_socket.getsockname()
20+
print(f"Listening on {host}:{port}")
21+
22+
try:
23+
while True:
24+
client_socket, client_address = server_socket.accept()
25+
print(f"Accepted connection from {client_address}")
26+
# Handle the client in a separate thread
27+
client_handler = threading.Thread(target=handle_client, args=(client_socket,))
28+
client_handler.start()
29+
except KeyboardInterrupt:
30+
print("Server shutting down.")
31+
finally:
32+
# Close the server socket
33+
server_socket.close()
34+
35+
if __name__ == "__main__":
36+
# Run the echo server
37+
echo_server()

tests/runtime-tests/src/lib.rs

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
use std::{
22
io::Read,
33
path::{Path, PathBuf},
4+
process::{Command, Stdio},
45
};
56

6-
use anyhow::Context;
7+
use anyhow::{bail, Context};
78

89
/// Configuration for the test suite
910
pub struct Config {
@@ -54,12 +55,92 @@ pub fn bootstrap_and_run(test_path: &Path, config: &Config) -> Result<(), anyhow
5455
.context("failed to produce a temporary directory to run the test in")?;
5556
log::trace!("Temporary directory: {}", temp.path().display());
5657
copy_manifest(test_path, &temp)?;
57-
let spin = Spin::start(&config.spin_binary_path, temp.path())?;
58+
let mut services = start_services(test_path)?;
59+
let spin = Spin::start(&config.spin_binary_path, temp.path(), &mut services)?;
5860
log::debug!("Spin started on port {}.", spin.port());
5961
run_test(test_path, spin, config.on_error);
6062
Ok(())
6163
}
6264

65+
fn start_services(test_path: &Path) -> anyhow::Result<Services> {
66+
let services_config_path = test_path.join("services");
67+
let children = if services_config_path.exists() {
68+
let services = std::fs::read_to_string(&services_config_path)
69+
.context("could not read services file")?;
70+
let service_files = services.lines().filter_map(|s| {
71+
let s = s.trim();
72+
(!s.is_empty()).then_some(Path::new(s))
73+
});
74+
// TODO: make this more robust so that it is not just assumed where the services definitions are
75+
let services_path = test_path
76+
.parent()
77+
.unwrap()
78+
.parent()
79+
.unwrap()
80+
.join("services");
81+
let mut services = Vec::new();
82+
for service_file in service_files {
83+
let service_name = service_file.file_stem().unwrap().to_str().unwrap();
84+
let child = match service_file.extension().and_then(|e| e.to_str()) {
85+
Some("py") => {
86+
let mut lock =
87+
fslock::LockFile::open(&services_path.join(format!("{service_name}.lock")))
88+
.context("failed to open service file lock")?;
89+
lock.lock().context("failed to obtain service file lock")?;
90+
let child = python()
91+
.arg(services_path.join(service_file).display().to_string())
92+
// Ignore stdout
93+
.stdout(Stdio::null())
94+
.spawn()
95+
.context("service failed to spawn")?;
96+
(child, Some(lock))
97+
}
98+
_ => bail!("unsupported service type found: {service_name}",),
99+
};
100+
services.push(child);
101+
}
102+
services
103+
} else {
104+
Vec::new()
105+
};
106+
107+
Ok(Services { children })
108+
}
109+
110+
fn python() -> Command {
111+
Command::new("python3")
112+
}
113+
114+
struct Services {
115+
children: Vec<(std::process::Child, Option<fslock::LockFile>)>,
116+
}
117+
118+
impl Services {
119+
fn error(&mut self) -> std::io::Result<()> {
120+
for (child, _) in &mut self.children {
121+
let exit = child.try_wait()?;
122+
if exit.is_some() {
123+
return Err(std::io::Error::new(
124+
std::io::ErrorKind::Interrupted,
125+
"process exited early",
126+
));
127+
}
128+
}
129+
Ok(())
130+
}
131+
}
132+
133+
impl Drop for Services {
134+
fn drop(&mut self) {
135+
for (child, lock) in &mut self.children {
136+
let _ = child.kill();
137+
if let Some(lock) = lock {
138+
let _ = lock.unlock();
139+
}
140+
}
141+
}
142+
}
143+
63144
/// Run an individual test
64145
fn run_test(test_path: &Path, mut spin: Spin, on_error: OnTestError) {
65146
// macro which will look at `on_error` and do the right thing
@@ -68,7 +149,7 @@ fn run_test(test_path: &Path, mut spin: Spin, on_error: OnTestError) {
68149
match $on_error {
69150
OnTestError::Panic => panic!($($arg)*),
70151
OnTestError::Log => {
71-
println!($($arg)*);
152+
eprintln!($($arg)*);
72153
return;
73154
}
74155
}
@@ -131,6 +212,9 @@ fn run_test(test_path: &Path, mut spin: Spin, on_error: OnTestError) {
131212
error!(on_error, "Test '{}' errored: {e}", test_path.display());
132213
}
133214
}
215+
if let OnTestError::Log = on_error {
216+
println!("'{}' passed", test_path.display())
217+
}
134218
}
135219

136220
/// Copies the test dir's manifest file into the temporary directory
@@ -228,14 +312,18 @@ struct Spin {
228312
}
229313

230314
impl Spin {
231-
fn start(spin_binary_path: &Path, current_dir: &Path) -> Result<Self, anyhow::Error> {
315+
fn start(
316+
spin_binary_path: &Path,
317+
current_dir: &Path,
318+
services: &mut Services,
319+
) -> Result<Self, anyhow::Error> {
232320
let port = get_random_port()?;
233-
let mut child = std::process::Command::new(spin_binary_path)
321+
let mut child = Command::new(spin_binary_path)
234322
.arg("up")
235323
.current_dir(current_dir)
236324
.args(["--listen", &format!("127.0.0.1:{port}")])
237-
.stdout(std::process::Stdio::piped())
238-
.stderr(std::process::Stdio::piped())
325+
.stdout(Stdio::piped())
326+
.stderr(Stdio::piped())
239327
.spawn()?;
240328
let stdout = OutputStream::new(child.stdout.take().unwrap());
241329
let stderr = OutputStream::new(child.stderr.take().unwrap());
@@ -247,6 +335,7 @@ impl Spin {
247335
port,
248336
};
249337
for _ in 0..80 {
338+
services.error()?;
250339
match std::net::TcpStream::connect(format!("127.0.0.1:{port}")) {
251340
Ok(_) => return Ok(spin),
252341
Err(e) => {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
tcp-echo.py

tests/runtime-tests/tests/tcp-sockets-ip-range/spin.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ component = "test"
1111

1212
[component.test]
1313
source = "{{tcp-sockets}}"
14-
allowed_outbound_hosts = ["*://127.0.0.0/24:5001"]
14+
allowed_outbound_hosts = ["*://127.0.0.0/24:6001"]

tests/runtime-tests/tests/tcp-sockets-no-ip-permission/spin.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ component = "test"
1212
[component.test]
1313
source = "{{tcp-sockets}}"
1414
# Component expects 127.0.0.1 but we only allow 127.0.0.2
15-
allowed_outbound_hosts = ["*://127.0.0.2:5001"]
15+
allowed_outbound_hosts = ["*://127.0.0.2:6001"]

0 commit comments

Comments
 (0)