Skip to content

Commit a7c22c4

Browse files
committed
test(vm): gate gateway resume coverage
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 3ab05fb commit a7c22c4

4 files changed

Lines changed: 206 additions & 36 deletions

File tree

‎e2e/rust/Cargo.toml‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ e2e-docker-gpu = ["e2e-docker", "e2e-gpu"]
2929
e2e-kubernetes = ["e2e"]
3030
e2e-podman = ["e2e", "e2e-host-gateway"]
3131
e2e-podman-gpu = ["e2e-podman", "e2e-gpu"]
32+
e2e-vm = ["e2e"]
3233

3334
[[test]]
3435
name = "custom_image"
@@ -43,7 +44,12 @@ required-features = ["e2e-docker"]
4344
[[test]]
4445
name = "gateway_resume"
4546
path = "tests/gateway_resume.rs"
46-
required-features = ["e2e"]
47+
required-features = ["e2e-docker"]
48+
49+
[[test]]
50+
name = "vm_gateway_resume"
51+
path = "tests/vm_gateway_resume.rs"
52+
required-features = ["e2e-vm"]
4753

4854
[[test]]
4955
name = "websocket_conformance"

‎e2e/rust/e2e-vm.sh‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ COMPRESSED_DIR="${ROOT}/target/vm-runtime-compressed"
4848
GATEWAY_BIN="${ROOT}/target/debug/openshell-gateway"
4949
DRIVER_BIN="${ROOT}/target/debug/openshell-driver-vm"
5050
E2E_TEST="${OPENSHELL_E2E_VM_TEST:-smoke}"
51+
E2E_FEATURES="${OPENSHELL_E2E_VM_FEATURES:-e2e-vm}"
5152

5253
# The VM driver places `compute-driver.sock` under --vm-driver-state-dir.
5354
# AF_UNIX SUN_LEN is 104 bytes on macOS (108 on Linux), so paths anchored
@@ -246,10 +247,10 @@ e2e_export_gateway_restart_metadata \
246247
# preparation; allow 180s for slower CI runners.
247248
export OPENSHELL_PROVISION_TIMEOUT="${SANDBOX_PROVISION_TIMEOUT}"
248249

249-
echo "==> Running e2e ${E2E_TEST} test (endpoint: ${OPENSHELL_GATEWAY_ENDPOINT})"
250+
echo "==> Running e2e ${E2E_TEST} test (features: ${E2E_FEATURES}, endpoint: ${OPENSHELL_GATEWAY_ENDPOINT})"
250251
cargo test \
251252
--manifest-path "${ROOT}/e2e/rust/Cargo.toml" \
252-
--features e2e \
253+
--features "${E2E_FEATURES}" \
253254
--test "${E2E_TEST}" \
254255
-- --nocapture
255256

‎e2e/rust/tests/gateway_resume.rs‎

Lines changed: 20 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33

44
#![cfg(feature = "e2e")]
55

6-
//! E2E coverage for resuming sandboxes after a standalone gateway restart.
6+
//! E2E coverage for resuming Docker sandboxes after a standalone gateway restart.
77
//!
8-
//! This intentionally targets managed local gateways started by e2e wrapper
9-
//! scripts. Existing-endpoint E2E runs do not own the gateway process, so they
10-
//! skip this restart-only coverage.
8+
//! This intentionally targets the Docker-driver gateway started by
9+
//! `e2e/with-docker-gateway.sh`. Existing-endpoint E2E runs do not own the
10+
//! gateway process, so they skip this restart-only coverage.
1111
1212
use std::process::{Command, Stdio};
1313
use std::time::{Duration, Instant};
@@ -210,25 +210,18 @@ async fn wait_for_container_running(
210210
}
211211

212212
#[tokio::test]
213-
async fn gateway_restart_resumes_running_sandbox() {
213+
async fn docker_gateway_restart_resumes_running_sandbox() {
214214
let Some(gateway) = ManagedGateway::from_env().expect("load managed e2e gateway metadata")
215215
else {
216216
eprintln!("Skipping gateway resume test: e2e gateway is not managed by this test run");
217217
return;
218218
};
219-
220-
let is_vm = std::env::var("OPENSHELL_E2E_DRIVER").as_deref() == Ok("vm");
221-
let docker_namespace = if is_vm {
222-
None
223-
} else {
224-
let namespace = std::env::var("OPENSHELL_E2E_DOCKER_NETWORK_NAME")
225-
.ok()
226-
.filter(|value| !value.trim().is_empty());
227-
if namespace.is_none() {
228-
eprintln!("Skipping gateway resume test: Docker e2e namespace is unavailable");
229-
return;
230-
}
231-
namespace
219+
let Some(namespace) = std::env::var("OPENSHELL_E2E_DOCKER_NETWORK_NAME")
220+
.ok()
221+
.filter(|value| !value.trim().is_empty())
222+
else {
223+
eprintln!("Skipping gateway resume test: Docker e2e namespace is unavailable");
224+
return;
232225
};
233226

234227
wait_for_healthy(Duration::from_secs(30))
@@ -254,28 +247,22 @@ async fn gateway_restart_resumes_running_sandbox() {
254247
"sandbox state was not written before restart:\n{before_restart}"
255248
);
256249

257-
if let Some(namespace) = docker_namespace.as_deref() {
258-
wait_for_container_running(namespace, &sandbox.name, true, Duration::from_secs(60))
259-
.await
260-
.expect("sandbox container should be running before gateway restart");
261-
}
250+
wait_for_container_running(&namespace, &sandbox.name, true, Duration::from_secs(60))
251+
.await
252+
.expect("sandbox container should be running before gateway restart");
262253

263254
gateway.stop().expect("stop e2e gateway");
264-
if let Some(namespace) = docker_namespace.as_deref() {
265-
wait_for_container_running(namespace, &sandbox.name, false, Duration::from_secs(120))
266-
.await
267-
.expect("gateway shutdown should stop managed Docker sandboxes");
268-
}
255+
wait_for_container_running(&namespace, &sandbox.name, false, Duration::from_secs(120))
256+
.await
257+
.expect("gateway shutdown should stop managed Docker sandboxes");
269258

270259
gateway.start().expect("restart e2e gateway");
271260
wait_for_healthy(Duration::from_secs(120))
272261
.await
273262
.expect("gateway should become healthy after restart");
274-
if let Some(namespace) = docker_namespace.as_deref() {
275-
wait_for_container_running(namespace, &sandbox.name, true, Duration::from_secs(120))
276-
.await
277-
.expect("gateway startup should resume the Docker sandbox container");
278-
}
263+
wait_for_container_running(&namespace, &sandbox.name, true, Duration::from_secs(120))
264+
.await
265+
.expect("gateway startup should resume the Docker sandbox container");
279266

280267
let names = sandbox_names().await.expect("list sandboxes after restart");
281268
assert!(
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
#![cfg(feature = "e2e-vm")]
5+
6+
//! VM-specific E2E coverage for resuming sandboxes after a standalone gateway
7+
//! restart.
8+
//!
9+
//! This test is gated behind the `e2e-vm` feature because it requires the VM
10+
//! driver runtime prepared by `e2e/rust/e2e-vm.sh`.
11+
12+
use std::process::Stdio;
13+
use std::time::{Duration, Instant};
14+
15+
use openshell_e2e::harness::binary::openshell_cmd;
16+
use openshell_e2e::harness::gateway::ManagedGateway;
17+
use openshell_e2e::harness::output::strip_ansi;
18+
use openshell_e2e::harness::sandbox::SandboxGuard;
19+
use tokio::time::sleep;
20+
21+
const READY_MARKER: &str = "vm-gateway-resume-ready";
22+
const RESUME_FILE: &str = "/sandbox/vm-gateway-resume-state";
23+
24+
async fn run_cli(args: &[&str]) -> (String, i32) {
25+
let mut cmd = openshell_cmd();
26+
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
27+
28+
let output = cmd.output().await.expect("spawn openshell");
29+
let stdout = String::from_utf8_lossy(&output.stdout);
30+
let stderr = String::from_utf8_lossy(&output.stderr);
31+
let combined = format!("{stdout}{stderr}");
32+
let code = output.status.code().unwrap_or(-1);
33+
(combined, code)
34+
}
35+
36+
async fn wait_for_healthy(timeout: Duration) -> Result<(), String> {
37+
let start = Instant::now();
38+
let mut last_output: String;
39+
40+
loop {
41+
let (output, code) = run_cli(&["status"]).await;
42+
let clean = strip_ansi(&output);
43+
let lower = clean.to_lowercase();
44+
if code == 0
45+
&& (lower.contains("healthy")
46+
|| lower.contains("running")
47+
|| lower.contains("connected"))
48+
{
49+
return Ok(());
50+
}
51+
last_output = clean;
52+
53+
if start.elapsed() > timeout {
54+
return Err(format!(
55+
"gateway did not become healthy within {}s. Last output:\n{last_output}",
56+
timeout.as_secs()
57+
));
58+
}
59+
sleep(Duration::from_secs(2)).await;
60+
}
61+
}
62+
63+
async fn sandbox_names() -> Result<Vec<String>, String> {
64+
let (output, code) = run_cli(&["sandbox", "list", "--names"]).await;
65+
let clean = strip_ansi(&output);
66+
if code != 0 {
67+
return Err(format!("sandbox list failed (exit {code}):\n{clean}"));
68+
}
69+
70+
Ok(clean
71+
.lines()
72+
.map(str::trim)
73+
.filter(|line| !line.is_empty())
74+
.map(ToOwned::to_owned)
75+
.collect())
76+
}
77+
78+
async fn wait_for_sandbox_exec_contains(
79+
sandbox_name: &str,
80+
command: &[&str],
81+
expected: &str,
82+
timeout: Duration,
83+
) -> Result<(), String> {
84+
let start = Instant::now();
85+
let mut last_output: String;
86+
87+
loop {
88+
let mut cmd = openshell_cmd();
89+
cmd.args(["sandbox", "exec", "--name", sandbox_name, "--no-tty", "--"])
90+
.args(command)
91+
.stdout(Stdio::piped())
92+
.stderr(Stdio::piped());
93+
94+
match cmd.output().await {
95+
Ok(output) => {
96+
let stdout = String::from_utf8_lossy(&output.stdout);
97+
let stderr = String::from_utf8_lossy(&output.stderr);
98+
last_output = strip_ansi(&format!("{stdout}{stderr}"));
99+
if output.status.success() && last_output.contains(expected) {
100+
return Ok(());
101+
}
102+
}
103+
Err(err) => {
104+
last_output = format!("failed to spawn openshell sandbox exec: {err}");
105+
}
106+
}
107+
108+
if start.elapsed() > timeout {
109+
return Err(format!(
110+
"sandbox '{sandbox_name}' exec did not produce '{expected}' within {}s. Last output:\n{last_output}",
111+
timeout.as_secs()
112+
));
113+
}
114+
sleep(Duration::from_secs(2)).await;
115+
}
116+
}
117+
118+
#[tokio::test]
119+
async fn vm_gateway_restart_resumes_running_sandbox() {
120+
if std::env::var("OPENSHELL_E2E_DRIVER").as_deref() != Ok("vm") {
121+
eprintln!("Skipping VM gateway resume test: e2e driver is not vm");
122+
return;
123+
}
124+
let Some(gateway) = ManagedGateway::from_env().expect("load managed e2e gateway metadata")
125+
else {
126+
eprintln!("Skipping VM gateway resume test: e2e gateway is not managed by this test run");
127+
return;
128+
};
129+
130+
wait_for_healthy(Duration::from_secs(30))
131+
.await
132+
.expect("gateway should start healthy");
133+
134+
let script = format!(
135+
"echo before-restart > {RESUME_FILE}; echo {READY_MARKER}; while true; do sleep 1; done"
136+
);
137+
let mut sandbox = SandboxGuard::create_keep(
138+
&["sh", "-lc", &script],
139+
READY_MARKER,
140+
)
141+
.await
142+
.expect("create long-running VM sandbox");
143+
144+
let before_restart = sandbox
145+
.exec(&["cat", RESUME_FILE])
146+
.await
147+
.expect("read VM sandbox state before restart");
148+
assert!(
149+
before_restart.contains("before-restart"),
150+
"VM sandbox state was not written before restart:\n{before_restart}"
151+
);
152+
153+
gateway.stop().expect("stop e2e gateway");
154+
gateway.start().expect("restart e2e gateway");
155+
wait_for_healthy(Duration::from_secs(120))
156+
.await
157+
.expect("gateway should become healthy after restart");
158+
159+
let names = sandbox_names().await.expect("list sandboxes after restart");
160+
assert!(
161+
names.contains(&sandbox.name),
162+
"sandbox '{}' should still be listed after gateway restart. Names: {names:?}",
163+
sandbox.name
164+
);
165+
166+
wait_for_sandbox_exec_contains(
167+
&sandbox.name,
168+
&["cat", RESUME_FILE],
169+
"before-restart",
170+
Duration::from_secs(240),
171+
)
172+
.await
173+
.expect("VM sandbox should become ready again with its overlay state preserved");
174+
175+
sandbox.cleanup().await;
176+
}

0 commit comments

Comments
 (0)