Skip to content

Commit c2eceee

Browse files
test(conformance): port sandbox lifecycle and API e2e tests to testkit
Move the driver-agnostic sandbox lifecycle, labels, templates, settings, image, process, Landlock and upload tests from e2e/rust to the conformance suite, the Podman OCI identity test to the podman driver suite, and the doctor, proxy-preflight and WebSocket tunnel checks to crate tests. Move the live-Internet benchmark to mise run perf:live-internet. Delete the legacy files and their references. Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
1 parent 9cf85f9 commit c2eceee

46 files changed

Lines changed: 2178 additions & 3068 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎TESTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -796,7 +796,7 @@ cargo test --manifest-path e2e/rust/Cargo.toml --features e2e --test sync
796796
Run a single Docker-only test directly with cargo:
797797

798798
```shell
799-
cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-docker --test docker_preflight
799+
cargo test --manifest-path e2e/rust/Cargo.toml --features e2e-docker --test docker_corporate_proxy
800800
```
801801

802802
The harness (`e2e/rust/src/harness/`) provides:
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! `openshell doctor check` against a fake `docker` on `PATH`, so the result
5+
//! doesn't depend on the host's Docker.
6+
7+
#![cfg(unix)]
8+
9+
use std::fs;
10+
use std::os::unix::fs::PermissionsExt;
11+
use std::path::Path;
12+
use std::process::{Command, Stdio};
13+
use std::time::{Duration, Instant};
14+
15+
struct Doctor {
16+
combined: String,
17+
code: i32,
18+
elapsed: Duration,
19+
}
20+
21+
/// Run `openshell doctor check` with only `bin` on `PATH`.
22+
fn doctor_check(bin: &Path) -> Doctor {
23+
let home = tempfile::tempdir().expect("create isolated config dir");
24+
let start = Instant::now();
25+
let output = Command::new(env!("CARGO_BIN_EXE_openshell"))
26+
.args(["doctor", "check"])
27+
.env("XDG_CONFIG_HOME", home.path())
28+
.env("HOME", home.path())
29+
.env("PATH", bin)
30+
.env("NO_COLOR", "1")
31+
.env_remove("DOCKER_HOST")
32+
.env_remove("OPENSHELL_GATEWAY")
33+
.env_remove("OPENSHELL_GATEWAY_ENDPOINT")
34+
.stdin(Stdio::null())
35+
.output()
36+
.expect("run openshell doctor check");
37+
Doctor {
38+
combined: format!(
39+
"{}{}",
40+
String::from_utf8_lossy(&output.stdout),
41+
String::from_utf8_lossy(&output.stderr)
42+
),
43+
code: output.status.code().unwrap_or(-1),
44+
elapsed: start.elapsed(),
45+
}
46+
}
47+
48+
/// Write an executable `docker` script to `bin` that records its arguments
49+
/// in `bin/docker-args` and then runs `body`.
50+
fn fake_docker(bin: &Path, body: &str) {
51+
let docker = bin.join("docker");
52+
let args = bin.join("docker-args");
53+
fs::write(
54+
&docker,
55+
format!(
56+
"#!/bin/sh\nprintf '%s\\n' \"$*\" > '{}'\n{body}\n",
57+
args.display()
58+
),
59+
)
60+
.expect("write fake docker");
61+
fs::set_permissions(&docker, fs::Permissions::from_mode(0o755)).expect("chmod fake docker");
62+
}
63+
64+
fn docker_args(bin: &Path) -> String {
65+
fs::read_to_string(bin.join("docker-args")).unwrap_or_default()
66+
}
67+
68+
#[test]
69+
fn doctor_check_passes_when_docker_info_succeeds() {
70+
let bin = tempfile::tempdir().expect("create fake bin dir");
71+
fake_docker(bin.path(), "echo 27.3.1");
72+
73+
let doctor = doctor_check(bin.path());
74+
assert_eq!(doctor.code, 0, "doctor check failed:\n{}", doctor.combined);
75+
assert_eq!(
76+
docker_args(bin.path()).trim(),
77+
"info --format {{.ServerVersion}}",
78+
"doctor check ran docker with unexpected arguments"
79+
);
80+
for expected in ["Docker", "ok (version 27.3.1)", "All checks passed"] {
81+
assert!(
82+
doctor.combined.contains(expected),
83+
"expected {expected:?} in doctor check output:\n{}",
84+
doctor.combined
85+
);
86+
}
87+
}
88+
89+
#[test]
90+
fn doctor_check_fails_fast_when_docker_info_fails() {
91+
let bin = tempfile::tempdir().expect("create fake bin dir");
92+
fake_docker(
93+
bin.path(),
94+
"echo 'Cannot connect to the Docker daemon' >&2\nexit 1",
95+
);
96+
97+
let doctor = doctor_check(bin.path());
98+
assert_ne!(
99+
doctor.code, 0,
100+
"doctor check passed although docker info failed:\n{}",
101+
doctor.combined
102+
);
103+
assert!(
104+
doctor.elapsed < Duration::from_secs(10),
105+
"doctor check took {:?}",
106+
doctor.elapsed
107+
);
108+
for expected in [
109+
"Docker",
110+
"FAILED",
111+
"docker info failed",
112+
"Cannot connect to the Docker daemon",
113+
] {
114+
assert!(
115+
doctor.combined.contains(expected),
116+
"expected {expected:?} in doctor check output:\n{}",
117+
doctor.combined
118+
);
119+
}
120+
assert!(
121+
!doctor.combined.contains("All checks passed"),
122+
"doctor check reported success:\n{}",
123+
doctor.combined
124+
);
125+
}
126+
127+
#[test]
128+
fn doctor_check_fails_without_docker_on_path() {
129+
let bin = tempfile::tempdir().expect("create empty bin dir");
130+
131+
let doctor = doctor_check(bin.path());
132+
assert_ne!(
133+
doctor.code, 0,
134+
"doctor check passed without docker:\n{}",
135+
doctor.combined
136+
);
137+
assert!(
138+
doctor.combined.contains("failed to execute docker info"),
139+
"doctor check did not report that docker could not run:\n{}",
140+
doctor.combined
141+
);
142+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! An incoherent driver proxy table is fatal, rather than leaving every dial
5+
//! direct while looking configured. A bypass list without a proxy URL means
6+
//! the operator believed proxying was in effect.
7+
8+
#![cfg(unix)]
9+
10+
use std::fs;
11+
use std::path::PathBuf;
12+
use std::process::Output;
13+
use std::time::Duration;
14+
15+
/// Drivers with an upstream proxy table, compiled into this gateway.
16+
const DRIVERS: &[&str] = &[
17+
#[cfg(feature = "compute-driver-docker")]
18+
"docker",
19+
#[cfg(feature = "compute-driver-podman")]
20+
"podman",
21+
#[cfg(feature = "compute-driver-vm")]
22+
"vm",
23+
];
24+
25+
struct Fixture {
26+
root: tempfile::TempDir,
27+
config: PathBuf,
28+
}
29+
30+
impl Fixture {
31+
/// A gateway config that selects `driver` and sets only `no_proxy` in its
32+
/// table.
33+
fn incoherent(driver: &str) -> Self {
34+
let root = tempfile::tempdir().expect("fixture root");
35+
let config = root.path().join("gateway.toml");
36+
fs::write(
37+
&config,
38+
format!(
39+
"[openshell]
40+
version = 2
41+
[openshell.gateway]
42+
disable_tls = true
43+
bind_address = \"127.0.0.1:0\"
44+
compute_driver = {driver:?}
45+
[openshell.gateway.auth]
46+
allow_unauthenticated_users = true
47+
[openshell.drivers.{driver}]
48+
no_proxy = \"10.0.0.0/8\"
49+
"
50+
),
51+
)
52+
.expect("gateway configuration");
53+
Self { root, config }
54+
}
55+
56+
async fn run(&self, args: &[&str]) -> Output {
57+
let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_openshell-gateway"));
58+
command
59+
.env_clear()
60+
.env("HOME", self.root.path())
61+
.env("PATH", "/usr/bin:/bin")
62+
.env("NO_COLOR", "1")
63+
.env("XDG_CONFIG_HOME", self.root.path().join("config-home"))
64+
.env("XDG_STATE_HOME", self.root.path().join("state-home"))
65+
.args(args)
66+
.kill_on_drop(true);
67+
tokio::time::timeout(Duration::from_secs(30), command.output())
68+
.await
69+
.expect("the gateway must exit instead of starting")
70+
.expect("run openshell-gateway")
71+
}
72+
73+
fn config_path(&self) -> &str {
74+
self.config.to_str().expect("UTF-8 config path")
75+
}
76+
}
77+
78+
fn combined(output: &Output) -> String {
79+
format!(
80+
"{}{}",
81+
String::from_utf8_lossy(&output.stdout),
82+
String::from_utf8_lossy(&output.stderr)
83+
)
84+
}
85+
86+
#[test]
87+
fn proxy_drivers_are_compiled_in() {
88+
assert!(
89+
!DRIVERS.is_empty(),
90+
"build with a compute driver that has a proxy table"
91+
);
92+
}
93+
94+
#[tokio::test]
95+
async fn preflight_rejects_no_proxy_without_proxy() {
96+
for driver in DRIVERS {
97+
let fixture = Fixture::incoherent(driver);
98+
let original = fs::read(&fixture.config).expect("original config");
99+
let output = fixture
100+
.run(&["config", "preflight", "--path", fixture.config_path()])
101+
.await;
102+
assert!(
103+
!output.status.success(),
104+
"preflight accepted an incoherent [openshell.drivers.{driver}] proxy table:\n{}",
105+
combined(&output)
106+
);
107+
assert!(
108+
combined(&output).contains("category=malformed"),
109+
"preflight did not classify the {driver} config as malformed:\n{}",
110+
combined(&output)
111+
);
112+
assert_eq!(
113+
fs::read(&fixture.config).expect("config after preflight"),
114+
original,
115+
"preflight changed the {driver} config"
116+
);
117+
}
118+
}
119+
120+
#[tokio::test]
121+
async fn startup_rejects_no_proxy_without_proxy_and_names_the_keys() {
122+
for driver in DRIVERS {
123+
let fixture = Fixture::incoherent(driver);
124+
let output = fixture.run(&["--config", fixture.config_path()]).await;
125+
let report = combined(&output);
126+
assert!(
127+
!output.status.success(),
128+
"the gateway started with an incoherent [openshell.drivers.{driver}] proxy table:\n{report}"
129+
);
130+
// Matched on the key names, not the wording, so error wrapping can
131+
// change.
132+
assert!(
133+
report.contains("no_proxy") && report.contains("https_proxy"),
134+
"the {driver} startup error does not name the offending keys:\n{report}"
135+
);
136+
}
137+
}

‎crates/openshell-server/src/lib.rs‎

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3099,11 +3099,21 @@ mod tests {
30993099

31003100
async fn start_plaintext_gateway_listener()
31013101
-> (SocketAddr, watch::Sender<bool>, tokio::task::JoinHandle<()>) {
3102+
start_plaintext_gateway_listener_with_tunnel(false).await
3103+
}
3104+
3105+
async fn start_plaintext_gateway_listener_with_tunnel(
3106+
enable_websocket_tunnel: bool,
3107+
) -> (SocketAddr, watch::Sender<bool>, tokio::task::JoinHandle<()>) {
31023108
let listener = TcpListener::bind("127.0.0.1:0")
31033109
.await
31043110
.expect("failed to bind test listener");
31053111
let listen_addr = listener.local_addr().expect("failed to read local addr");
3106-
let state = test_state(listen_addr, false).await;
3112+
let mut state = test_state(listen_addr, false).await;
3113+
Arc::get_mut(&mut state)
3114+
.expect("test server state should be uniquely owned")
3115+
.config
3116+
.enable_websocket_tunnel = enable_websocket_tunnel;
31073117
let service = MultiplexService::new(state);
31083118
let (shutdown_tx, shutdown_rx) = watch::channel(false);
31093119
let handle = tokio::spawn(serve_gateway_listener(
@@ -3159,6 +3169,68 @@ mod tests {
31593169
stop_listener(shutdown, handle).await;
31603170
}
31613171

3172+
/// The edge-tunnel client path: gRPC carried in binary frames over a
3173+
/// WebSocket to `/_ws_tunnel` on the production listener reaches the
3174+
/// gateway's own services.
3175+
#[tokio::test]
3176+
async fn production_gateway_listener_serves_grpc_through_websocket_tunnel() {
3177+
use futures::{SinkExt as _, StreamExt as _};
3178+
use openshell_core::proto::{
3179+
HealthRequest, ServiceStatus, open_shell_client::OpenShellClient,
3180+
};
3181+
use tokio_tungstenite::tungstenite::Message;
3182+
3183+
let (addr, shutdown, handle) = start_plaintext_gateway_listener_with_tunnel(true).await;
3184+
let ws_url = format!("ws://127.0.0.1:{}/_ws_tunnel", addr.port());
3185+
let channel = tonic::transport::Endpoint::from_static("http://edge-tunnel.invalid")
3186+
.connect_with_connector(tower::service_fn(move |_| {
3187+
let ws_url = ws_url.clone();
3188+
async move {
3189+
let (ws, _) = tokio_tungstenite::connect_async(ws_url)
3190+
.await
3191+
.map_err(Error::other)?;
3192+
let (mut ws_sink, mut ws_source) = ws.split();
3193+
let (client, bridge) = tokio::io::duplex(64 * 1024);
3194+
let (mut bridge_read, mut bridge_write) = tokio::io::split(bridge);
3195+
tokio::spawn(async move {
3196+
let mut buf = vec![0u8; 32 * 1024];
3197+
while let Ok(n @ 1..) = bridge_read.read(&mut buf).await {
3198+
if ws_sink
3199+
.send(Message::Binary(buf[..n].to_vec().into()))
3200+
.await
3201+
.is_err()
3202+
{
3203+
break;
3204+
}
3205+
}
3206+
let _ = ws_sink.close().await;
3207+
});
3208+
tokio::spawn(async move {
3209+
while let Some(Ok(Message::Binary(data))) = ws_source.next().await {
3210+
if bridge_write.write_all(&data).await.is_err() {
3211+
break;
3212+
}
3213+
}
3214+
let _ = bridge_write.shutdown().await;
3215+
});
3216+
Ok::<_, Error>(hyper_util::rt::TokioIo::new(client))
3217+
}
3218+
}))
3219+
.await
3220+
.expect("gRPC channel through the WebSocket tunnel");
3221+
3222+
let response = tokio::time::timeout(
3223+
Duration::from_secs(10),
3224+
OpenShellClient::new(channel).health(HealthRequest {}),
3225+
)
3226+
.await
3227+
.expect("health RPC through the tunnel timed out")
3228+
.expect("health RPC through the tunnel");
3229+
assert_eq!(response.get_ref().status, ServiceStatus::Healthy as i32);
3230+
3231+
stop_listener(shutdown, handle).await;
3232+
}
3233+
31623234
async fn send_plain_http(addr: SocketAddr, request: String) -> String {
31633235
let connect_addr: SocketAddr = format!("127.0.0.1:{}", addr.port())
31643236
.parse()

0 commit comments

Comments
 (0)