|
1 | | -use testing_framework::runtimes::spin_containerd_shim::SpinShim; |
| 1 | +use std::{ |
| 2 | + path::{Path, PathBuf}, |
| 3 | + process::{Command, Stdio}, |
| 4 | +}; |
| 5 | + |
| 6 | +use anyhow::Context as _; |
| 7 | +use test_environment::{ |
| 8 | + http::{Request, Response}, |
| 9 | + io::OutputStream, |
| 10 | + services::ServicesConfig, |
| 11 | + Runtime, TestEnvironment, TestEnvironmentConfig, |
| 12 | +}; |
2 | 13 |
|
3 | 14 | fn main() { |
4 | 15 | let tests_dir = conformance_tests::download_tests().unwrap(); |
| 16 | + let mut args = std::env::args().skip(1); |
| 17 | + let spin_binary = args |
| 18 | + .next() |
| 19 | + .expect("expected first arg to be path to Spin binary"); |
| 20 | + let ctr_binary = args |
| 21 | + .next() |
| 22 | + .expect("expected second arg to be path to ctr binary"); |
| 23 | + let ctr_binary = Path::new(&ctr_binary); |
5 | 24 |
|
6 | 25 | for test in conformance_tests::tests(&tests_dir).unwrap() { |
7 | | - let spin_binary = std::path::Path::new("/home/kagold/projects/temp/spin"); |
8 | | - let ctr_binary = "/usr/bin/ctr".into(); |
| 26 | + let spin_binary = PathBuf::from(spin_binary.clone()); |
9 | 27 | // Just using TTL.sh until we decide where to host these (local registry, ghcr, etc) |
10 | 28 | let oci_image = format!("ttl.sh/{}:72h", test.name); |
11 | | - let env_config = testing_framework::TestEnvironmentConfig::containerd_shim( |
12 | | - ctr_binary, |
| 29 | + let env_config = SpinShim::config( |
| 30 | + ctr_binary.into(), |
13 | 31 | oci_image.clone(), |
14 | 32 | move |e| { |
15 | 33 | // TODO: consider doing this outside the test environment since it is not a part of the runtime execution |
16 | 34 | e.copy_into(&test.manifest, "spin.toml")?; |
17 | 35 | e.copy_into(&test.component, test.component.file_name().unwrap())?; |
18 | | - SpinShim::regisry_push(spin_binary, &oci_image, e).unwrap(); |
| 36 | + SpinShim::regisry_push(&spin_binary, &oci_image, e).unwrap(); |
19 | 37 | Ok(()) |
20 | 38 | }, |
21 | | - testing_framework::ServicesConfig::none(), |
| 39 | + test_environment::services::ServicesConfig::none(), |
22 | 40 | ); |
23 | | - std::thread::sleep(std::time::Duration::from_secs(60)); |
24 | | - let mut env = testing_framework::TestEnvironment::up(env_config, |_| Ok(())).unwrap(); |
| 41 | + let mut env = TestEnvironment::up(env_config, |_| Ok(())).unwrap(); |
25 | 42 | let spin = env.runtime_mut(); |
26 | 43 | for invocation in test.config.invocations { |
27 | 44 | let conformance_tests::config::Invocation::Http(invocation) = invocation; |
28 | | - let headers = invocation |
| 45 | + invocation |
29 | 46 | .request |
30 | | - .headers |
31 | | - .iter() |
32 | | - .map(|h| (h.name.as_str(), h.value.as_str())) |
33 | | - .collect::<Vec<_>>(); |
34 | | - let request = testing_framework::http::Request::full( |
35 | | - match invocation.request.method { |
36 | | - conformance_tests::config::Method::GET => testing_framework::http::Method::GET, |
37 | | - conformance_tests::config::Method::POST => { |
38 | | - testing_framework::http::Method::POST |
39 | | - } |
40 | | - }, |
41 | | - &invocation.request.path, |
42 | | - &headers, |
43 | | - invocation.request.body, |
44 | | - ); |
45 | | - let response = spin.make_http_request(request).unwrap(); |
46 | | - let stderr = spin.stderr(); |
47 | | - let body = String::from_utf8(response.body()) |
48 | | - .unwrap_or_else(|_| String::from("invalid utf-8")); |
49 | | - assert_eq!( |
50 | | - response.status(), |
51 | | - invocation.response.status, |
52 | | - "request to Spin failed\nstderr:\n{stderr}\nbody:\n{body}", |
53 | | - ); |
| 47 | + .send(|request| spin.make_http_request(request)) |
| 48 | + .unwrap(); |
| 49 | + } |
| 50 | + println!("test passed: {}", test.name); |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +struct SpinShim { |
| 55 | + process: std::process::Child, |
| 56 | + #[allow(dead_code)] |
| 57 | + stdout: OutputStream, |
| 58 | + stderr: OutputStream, |
| 59 | + io_mode: IoMode, |
| 60 | +} |
| 61 | + |
| 62 | +/// `ctr run` invocations require an ID that is unique to all currently running instances. Since |
| 63 | +/// only one test runs at a time, we can reuse a constant ID. |
| 64 | +const CTR_RUN_ID: &str = "run-id"; |
| 65 | + |
| 66 | +impl SpinShim { |
| 67 | + pub fn config( |
| 68 | + ctr_binary: PathBuf, |
| 69 | + oci_image: String, |
| 70 | + preboot: impl FnOnce(&mut TestEnvironment<SpinShim>) -> anyhow::Result<()> + 'static, |
| 71 | + services_config: ServicesConfig, |
| 72 | + ) -> TestEnvironmentConfig<SpinShim> { |
| 73 | + TestEnvironmentConfig { |
| 74 | + services_config, |
| 75 | + create_runtime: Box::new(move |env| { |
| 76 | + preboot(env)?; |
| 77 | + SpinShim::image_pull(&ctr_binary, &oci_image)?; |
| 78 | + SpinShim::start(&ctr_binary, env, &oci_image, CTR_RUN_ID) |
| 79 | + }), |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + pub fn regisry_push<R>( |
| 84 | + spin_binary_path: &Path, |
| 85 | + image: &str, |
| 86 | + env: &mut TestEnvironment<R>, |
| 87 | + ) -> anyhow::Result<()> { |
| 88 | + // TODO: consider enabling configuring a port |
| 89 | + Command::new(spin_binary_path) |
| 90 | + .args(["registry", "push"]) |
| 91 | + .arg(image) |
| 92 | + .current_dir(env.path()) |
| 93 | + .output() |
| 94 | + .context("failed to push spin app to registry with 'spin'")?; |
| 95 | + // TODO: assess output |
| 96 | + Ok(()) |
| 97 | + } |
| 98 | + |
| 99 | + pub fn image_pull(ctr_binary_path: &Path, image: &str) -> anyhow::Result<()> { |
| 100 | + // TODO: consider enabling configuring a port |
| 101 | + Command::new(ctr_binary_path) |
| 102 | + .args(["image", "pull"]) |
| 103 | + .arg(image) |
| 104 | + .output() |
| 105 | + .context("failed to pull spin app with 'ctr'")?; |
| 106 | + // TODO: assess output |
| 107 | + Ok(()) |
| 108 | + } |
54 | 109 |
|
55 | | - let mut actual_headers = response |
56 | | - .headers() |
57 | | - .iter() |
58 | | - .map(|(k, v)| (k.to_lowercase(), v.to_lowercase())) |
59 | | - .collect::<std::collections::HashMap<_, _>>(); |
60 | | - for expected_header in invocation.response.headers { |
61 | | - let expected_name = expected_header.name.to_lowercase(); |
62 | | - let expected_value = expected_header.value.map(|v| v.to_lowercase()); |
63 | | - let actual_value = actual_headers.remove(&expected_name); |
64 | | - let Some(actual_value) = actual_value.as_deref() else { |
65 | | - if expected_header.optional { |
66 | | - continue; |
67 | | - } else { |
68 | | - panic!( |
69 | | - "expected header {name} not found in response", |
70 | | - name = expected_header.name |
71 | | - ) |
72 | | - } |
73 | | - }; |
74 | | - if let Some(expected_value) = expected_value { |
75 | | - assert_eq!(actual_value, expected_value); |
| 110 | + /// Start the Spin app using `ctr run` |
| 111 | + /// Equivalent of `sudo ctr run --rm --net-host --runtime io.containerd.spin.v2 ttl.sh/myapp:48h ctr-run-id bogus-arg` for image `ttl.sh/myapp:48h` and run id `ctr-run-id` |
| 112 | + pub fn start<R>( |
| 113 | + ctr_binary_path: &Path, |
| 114 | + env: &mut TestEnvironment<R>, |
| 115 | + image: &str, |
| 116 | + ctr_run_id: &str, |
| 117 | + ) -> anyhow::Result<Self> { |
| 118 | + let port = 80; |
| 119 | + let mut ctr_cmd = std::process::Command::new(ctr_binary_path); |
| 120 | + let child = ctr_cmd |
| 121 | + .arg("run") |
| 122 | + .args([ |
| 123 | + "--memory-limit", |
| 124 | + "10000", |
| 125 | + "--rm", |
| 126 | + "--net-host", |
| 127 | + "--runtime", |
| 128 | + "io.containerd.spin.v2", |
| 129 | + ]) |
| 130 | + .arg(image) |
| 131 | + .arg(ctr_run_id) |
| 132 | + .arg("bogus-arg") |
| 133 | + .stdout(Stdio::piped()) |
| 134 | + .stderr(Stdio::piped()); |
| 135 | + for (key, value) in env.env_vars() { |
| 136 | + child.env(key, value); |
| 137 | + } |
| 138 | + let mut child = child.spawn()?; |
| 139 | + let stdout = OutputStream::new(child.stdout.take().unwrap()); |
| 140 | + let stderr = OutputStream::new(child.stderr.take().unwrap()); |
| 141 | + log::debug!("Awaiting shim binary to start up on port {port}..."); |
| 142 | + let mut spin = Self { |
| 143 | + process: child, |
| 144 | + stdout, |
| 145 | + stderr, |
| 146 | + io_mode: IoMode::Http(port), |
| 147 | + }; |
| 148 | + let start = std::time::Instant::now(); |
| 149 | + loop { |
| 150 | + match std::net::TcpStream::connect(format!("127.0.0.1:{port}")) { |
| 151 | + Ok(_) => { |
| 152 | + log::debug!("Spin shim started on port {port}."); |
| 153 | + return Ok(spin); |
| 154 | + } |
| 155 | + Err(e) => { |
| 156 | + let stderr = spin.stderr.output_as_str().unwrap_or("<non-utf8>"); |
| 157 | + log::trace!("Checking that the Spin server started returned an error: {e}"); |
| 158 | + log::trace!("Current spin stderr = '{stderr}'"); |
76 | 159 | } |
77 | 160 | } |
78 | | - if !actual_headers.is_empty() { |
79 | | - panic!("unexpected headers: {actual_headers:?}"); |
| 161 | + if let Some(status) = spin.try_wait()? { |
| 162 | + anyhow::bail!( |
| 163 | + "Shim exited early with status code {:?}\n{}{}", |
| 164 | + status.code(), |
| 165 | + spin.stdout.output_as_str().unwrap_or("<non-utf8>"), |
| 166 | + spin.stderr.output_as_str().unwrap_or("<non-utf8>") |
| 167 | + ); |
80 | 168 | } |
81 | 169 |
|
82 | | - if let Some(expected_body) = invocation.response.body { |
83 | | - assert_eq!(body, expected_body); |
| 170 | + if start.elapsed() > std::time::Duration::from_secs(2 * 60) { |
| 171 | + break; |
84 | 172 | } |
| 173 | + std::thread::sleep(std::time::Duration::from_millis(50)); |
85 | 174 | } |
86 | | - println!("test passed: {}", test.name); |
| 175 | + anyhow::bail!( |
| 176 | + "`ctr run` did not start server or error after two minutes. stderr:\n\t{}", |
| 177 | + spin.stderr.output_as_str().unwrap_or("<non-utf8>") |
| 178 | + ) |
| 179 | + } |
| 180 | + |
| 181 | + /// Make an HTTP request against Spin |
| 182 | + /// |
| 183 | + /// Will fail if Spin has already exited or if the io mode is not HTTP |
| 184 | + pub fn make_http_request(&mut self, request: Request<'_, String>) -> anyhow::Result<Response> { |
| 185 | + let IoMode::Http(port) = self.io_mode; |
| 186 | + if let Some(status) = self.try_wait()? { |
| 187 | + anyhow::bail!( |
| 188 | + "make_http_request - shim exited early with status code {:?}", |
| 189 | + status.code() |
| 190 | + ); |
| 191 | + } |
| 192 | + log::debug!("Connecting to HTTP server on port {port}..."); |
| 193 | + let response = request.send("localhost", port).unwrap(); |
| 194 | + log::debug!("Awaiting response from server"); |
| 195 | + if let Some(status) = self.try_wait()? { |
| 196 | + anyhow::bail!("Spin exited early with status code {:?}", status.code()); |
| 197 | + } |
| 198 | + println!("Response: {}", response.status()); |
| 199 | + Ok(response) |
| 200 | + } |
| 201 | + |
| 202 | + pub fn stderr(&mut self) -> &str { |
| 203 | + self.stderr.output_as_str().unwrap_or("<non-utf8>") |
| 204 | + } |
| 205 | + |
| 206 | + fn try_wait(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> { |
| 207 | + self.process.try_wait() |
| 208 | + } |
| 209 | +} |
| 210 | + |
| 211 | +impl Drop for SpinShim { |
| 212 | + fn drop(&mut self) { |
| 213 | + kill_process(&mut self.process); |
| 214 | + } |
| 215 | +} |
| 216 | + |
| 217 | +impl Runtime for SpinShim { |
| 218 | + fn error(&mut self) -> anyhow::Result<()> { |
| 219 | + if self.try_wait()?.is_some() { |
| 220 | + anyhow::bail!("Containerd shim spin exited early: {}", self.stderr()); |
| 221 | + } |
| 222 | + |
| 223 | + Ok(()) |
| 224 | + } |
| 225 | +} |
| 226 | + |
| 227 | +/// How this instance is communicating with the outside world |
| 228 | +pub enum IoMode { |
| 229 | + /// An http server is running on this port |
| 230 | + Http(u16), |
| 231 | +} |
| 232 | + |
| 233 | +/// Helper function to kill a process |
| 234 | +fn kill_process(process: &mut std::process::Child) { |
| 235 | + #[cfg(windows)] |
| 236 | + { |
| 237 | + let _ = process.kill(); |
| 238 | + } |
| 239 | + #[cfg(not(windows))] |
| 240 | + { |
| 241 | + let pid = nix::unistd::Pid::from_raw(process.id() as i32); |
| 242 | + let _ = nix::sys::signal::kill(pid, nix::sys::signal::SIGTERM); |
87 | 243 | } |
88 | 244 | } |
0 commit comments