Skip to content

Commit 77ab665

Browse files
committed
test(conformance): add sandbox environment capability
Signed-off-by: Evan Lezar <elezar@nvidia.com>
1 parent 913130c commit 77ab665

6 files changed

Lines changed: 286 additions & 38 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ pub use scenarios::{
2727
FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO,
2828
FILE_TRANSFER_ROUND_TRIP_SCENARIO, FILE_TRANSFER_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO,
2929
NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO, SANDBOX_CANONICAL_MAIN_SCENARIO,
30-
SANDBOX_LIFECYCLE_SCENARIO, SANDBOX_STOP_START_SCENARIO, SMOKE_SCENARIO,
30+
SANDBOX_ENVIRONMENT_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO, SANDBOX_STOP_START_SCENARIO,
31+
SMOKE_SCENARIO,
3132
};
3233

3334
/// An installed conformance scenario.
@@ -49,6 +50,7 @@ impl Scenario {
4950
const SCENARIOS: &[Scenario] = &[
5051
SMOKE_SCENARIO,
5152
SANDBOX_LIFECYCLE_SCENARIO,
53+
SANDBOX_ENVIRONMENT_SCENARIO,
5254
FILE_TRANSFER_SCENARIO,
5355
MECHANISTIC_PROPOSAL_SCENARIO,
5456
NEW_HOSTNAME_PROPOSAL_SCENARIO,

‎crates/openshell-conformance/src/scenarios/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
66
mod file_transfer;
77
mod policy_behavior;
8+
mod sandbox_environment;
89
mod sandbox_lifecycle;
910
mod smoke;
1011

@@ -15,6 +16,7 @@ pub use file_transfer::{
1516
pub use policy_behavior::{
1617
MECHANISTIC_PROPOSAL_SCENARIO, NEW_HOSTNAME_PROPOSAL_SCENARIO, POLICY_LOCAL_SCENARIO,
1718
};
19+
pub use sandbox_environment::SANDBOX_ENVIRONMENT_SCENARIO;
1820
pub use sandbox_lifecycle::{
1921
SANDBOX_CANONICAL_MAIN_SCENARIO, SANDBOX_LIFECYCLE_SCENARIO, SANDBOX_STOP_START_SCENARIO,
2022
};
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Portable sandbox environment conformance scenarios.
5+
6+
use std::time::Duration;
7+
8+
use serde::Deserialize;
9+
10+
use crate::{OpenShellRunner, Poll, Scenario, ScenarioFuture};
11+
12+
const CREATE_TIMEOUT: Duration = Duration::from_mins(10);
13+
const COMMAND_TIMEOUT: Duration = Duration::from_mins(2);
14+
const TRANSITION_TIMEOUT: Duration = Duration::from_mins(4);
15+
const TRANSITION_INTERVAL: Duration = Duration::from_secs(2);
16+
17+
#[derive(Debug, Deserialize)]
18+
struct SandboxState {
19+
name: String,
20+
phase: String,
21+
}
22+
23+
/// Certify declared environment propagation to the canonical main and exec.
24+
pub const SANDBOX_ENVIRONMENT_SCENARIO: Scenario = Scenario {
25+
name: "sandbox-environment",
26+
description: "Verify declared environment propagation to canonical-main and exec processes.",
27+
run: run_sandbox_environment,
28+
};
29+
30+
fn run_sandbox_environment(runner: &mut OpenShellRunner) -> ScenarioFuture<'_> {
31+
Box::pin(async move {
32+
let sandbox_name = format!("ct-{}-env", runner.id());
33+
let sentinel_path = "/sandbox/.openshell-declared-environment";
34+
let main = format!(
35+
"printf '%s\\n' \"${{REPRO_SENTINEL:-missing}}\" > '{sentinel_path}'; exec sleep infinity"
36+
);
37+
38+
create_sandbox(runner, &sandbox_name, &main).await?;
39+
exec_expect_exact(
40+
runner,
41+
&sandbox_name,
42+
"read-main-environment",
43+
&[
44+
"sh",
45+
"-c",
46+
&format!(
47+
"while [ ! -f '{sentinel_path}' ]; do sleep 0.05; done; cat '{sentinel_path}'"
48+
),
49+
],
50+
"present\n",
51+
)
52+
.await?;
53+
let exec_environment = format!("printf '%s\\n' \"${{{}:-missing}}\"", "REPRO_SENTINEL");
54+
exec_expect_exact(
55+
runner,
56+
&sandbox_name,
57+
"read-exec-environment",
58+
&["sh", "-c", &exec_environment],
59+
"present\n",
60+
)
61+
.await?;
62+
delete_and_confirm_absent(runner, &sandbox_name).await
63+
})
64+
}
65+
66+
async fn create_sandbox(
67+
runner: &mut OpenShellRunner,
68+
sandbox_name: &str,
69+
main: &str,
70+
) -> Result<(), String> {
71+
runner.track_sandbox(sandbox_name);
72+
let create = runner
73+
.step("create")
74+
.description(format!(
75+
"sandbox '{sandbox_name}' is created with a declared environment"
76+
))
77+
.with_timeout(CREATE_TIMEOUT)
78+
.run(&[
79+
"sandbox",
80+
"create",
81+
"--name",
82+
sandbox_name,
83+
"--detach",
84+
"--no-tty",
85+
"--no-auto-providers",
86+
"--env",
87+
"REPRO_SENTINEL=present",
88+
"--",
89+
"sh",
90+
"-lc",
91+
main,
92+
])
93+
.await
94+
.map_err(|error| error.to_string())?;
95+
create.require_success()?;
96+
wait_for_phase(runner, sandbox_name, "Ready", "ready").await
97+
}
98+
99+
async fn exec_expect_exact(
100+
runner: &OpenShellRunner,
101+
sandbox_name: &str,
102+
step: &str,
103+
command: &[&str],
104+
expected_stdout: &str,
105+
) -> Result<(), String> {
106+
let mut args = vec!["sandbox", "exec", "--name", sandbox_name, "--no-tty", "--"];
107+
args.extend_from_slice(command);
108+
let result = runner
109+
.step(step)
110+
.description(format!("sandbox '{sandbox_name}' exec {step} succeeds"))
111+
.with_timeout(COMMAND_TIMEOUT)
112+
.run(&args)
113+
.await
114+
.map_err(|error| error.to_string())?;
115+
result.require_success()?;
116+
if result.stdout() == expected_stdout {
117+
Ok(())
118+
} else {
119+
Err(result.failure_diagnostic(&format!("stdout is exactly {expected_stdout:?}")))
120+
}
121+
}
122+
123+
async fn delete_and_confirm_absent(
124+
runner: &mut OpenShellRunner,
125+
sandbox_name: &str,
126+
) -> Result<(), String> {
127+
let delete = runner
128+
.step("delete")
129+
.description(format!("sandbox '{sandbox_name}' deletion succeeds"))
130+
.with_timeout(COMMAND_TIMEOUT)
131+
.run(&["sandbox", "delete", sandbox_name])
132+
.await
133+
.map_err(|error| error.to_string())?;
134+
delete.require_success()?;
135+
wait_for_absence(runner, sandbox_name, "deleted").await?;
136+
runner.forget_sandbox(sandbox_name);
137+
Ok(())
138+
}
139+
140+
async fn wait_for_phase(
141+
runner: &mut OpenShellRunner,
142+
sandbox_name: &str,
143+
expected_phase: &str,
144+
step: &str,
145+
) -> Result<(), String> {
146+
let sandbox_name = sandbox_name.to_string();
147+
let expected_phase = expected_phase.to_string();
148+
let step = step.to_string();
149+
let poll_step = step.clone();
150+
runner
151+
.poll_until(
152+
&poll_step,
153+
TRANSITION_TIMEOUT,
154+
TRANSITION_INTERVAL,
155+
async move |runner| {
156+
let result = runner
157+
.step(format!("{step}/get"))
158+
.description(format!(
159+
"sandbox '{sandbox_name}' reaches phase {expected_phase}"
160+
))
161+
.with_timeout(COMMAND_TIMEOUT)
162+
.run(&["sandbox", "get", &sandbox_name, "--output", "json"])
163+
.await;
164+
match result {
165+
Ok(result) if !result.success() => {
166+
Poll::Pending(result.failure_diagnostic(&format!(
167+
"sandbox '{sandbox_name}' can be retrieved"
168+
)))
169+
}
170+
Ok(result) => match result.json::<SandboxState>() {
171+
Ok(state) if state.name != sandbox_name => Poll::Failed(format!(
172+
"sandbox get returned {:?}; expected '{sandbox_name}'",
173+
state.name
174+
)),
175+
Ok(state) if state.phase == expected_phase => Poll::Ready(()),
176+
Ok(state) => Poll::Pending(format!(
177+
"sandbox '{sandbox_name}' phase is {:?}; expected {expected_phase:?}",
178+
state.phase
179+
)),
180+
Err(error) => Poll::Failed(error.to_string()),
181+
},
182+
Err(error) => Poll::Pending(error.to_string()),
183+
}
184+
},
185+
)
186+
.await
187+
.map_err(|error| error.to_string())
188+
}
189+
190+
async fn wait_for_absence(
191+
runner: &mut OpenShellRunner,
192+
sandbox_name: &str,
193+
step: &str,
194+
) -> Result<(), String> {
195+
let sandbox_name = sandbox_name.to_string();
196+
let step = step.to_string();
197+
let poll_step = step.clone();
198+
runner
199+
.poll_until(
200+
&poll_step,
201+
TRANSITION_TIMEOUT,
202+
TRANSITION_INTERVAL,
203+
async move |runner| {
204+
let result = runner
205+
.step(format!("{step}/get"))
206+
.description(format!("sandbox '{sandbox_name}' is no longer retrievable"))
207+
.with_timeout(COMMAND_TIMEOUT)
208+
.run(&["sandbox", "get", &sandbox_name, "--output", "json"])
209+
.await;
210+
match result {
211+
Ok(result) if !result.success() => Poll::Ready(()),
212+
Ok(_) => {
213+
Poll::Pending(format!("sandbox '{sandbox_name}' is still retrievable"))
214+
}
215+
Err(error) => Poll::Pending(error.to_string()),
216+
}
217+
},
218+
)
219+
.await
220+
.map_err(|error| error.to_string())
221+
}

‎e2e/rust/tests/sandbox_lifecycle.rs‎

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -736,41 +736,41 @@ async fn canonical_main_nonzero_exit_preserves_status() {
736736

737737
#[tokio::test]
738738
#[serial(sandbox_lifecycle)]
739-
async fn canonical_main_and_exec_receive_declared_environment() {
740-
for mode in ["--tty", "--no-tty"] {
741-
let script = r#"printf 'declared_env=%s\n' "${REPRO_SENTINEL:-missing}"; while true; do sleep 1; done"#;
742-
let mut sandbox = SandboxGuard::create_keep_with_args(
743-
&[
744-
mode,
745-
"--no-auto-providers",
746-
"--env",
747-
"REPRO_SENTINEL=present",
748-
],
749-
&["sh", "-c", script],
750-
"declared_env=",
751-
)
739+
async fn canonical_tty_main_and_exec_receive_declared_environment() {
740+
let script =
741+
r#"printf 'declared_env=%s\n' "${REPRO_SENTINEL:-missing}"; while true; do sleep 1; done"#;
742+
let mut sandbox = SandboxGuard::create_keep_with_args(
743+
&[
744+
"--tty",
745+
"--no-auto-providers",
746+
"--env",
747+
"REPRO_SENTINEL=present",
748+
],
749+
&["sh", "-c", script],
750+
"declared_env=",
751+
)
752+
.await
753+
.expect("create canonical TTY process with declared environment");
754+
let initial = normalize_output(&sandbox.create_output);
755+
let later = sandbox
756+
.exec(&[
757+
"sh",
758+
"-c",
759+
r#"printf 'declared_env=%s\n' "${REPRO_SENTINEL:-missing}""#,
760+
])
752761
.await
753-
.expect("create canonical process with declared environment");
754-
let initial = normalize_output(&sandbox.create_output);
755-
let later = sandbox
756-
.exec(&[
757-
"sh",
758-
"-c",
759-
r#"printf 'declared_env=%s\n' "${REPRO_SENTINEL:-missing}""#,
760-
])
761-
.await;
762-
sandbox.cleanup().await;
762+
.expect("exec environment probe");
763+
sandbox.cleanup().await;
763764

764-
assert!(
765-
initial.lines().any(|line| line == "declared_env=present"),
766-
"initial process must receive declared environment ({mode}): {initial}"
767-
);
768-
let later = normalize_output(&later.expect("exec environment probe"));
769-
assert!(
770-
later.lines().any(|line| line == "declared_env=present"),
771-
"exec must receive the same declared environment ({mode}): {later}"
772-
);
773-
}
765+
assert!(
766+
initial.lines().any(|line| line == "declared_env=present"),
767+
"initial TTY process must receive declared environment: {initial}"
768+
);
769+
let later = normalize_output(&later);
770+
assert!(
771+
later.lines().any(|line| line == "declared_env=present"),
772+
"exec must receive the same declared environment as the TTY main: {later}"
773+
);
774774
}
775775

776776
#[tokio::test]

‎tests/artifacts.nix‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,11 @@ let
129129
"proxy_egress_pipeline"
130130
# Conformance covers stop/start workspace preservation, deletion while
131131
# stopped, and detached canonical-main terminal state, fast-exit
132-
# classification, persistence, and deletion. TTY streaming and CLI
133-
# exit-code propagation, environment, attachment replay/recovery, signals,
134-
# and no-keep cases remain. Nextest archive filters cannot select individual
135-
# tests, so keep the complete binary in the follow-up bucket.
132+
# classification, environment propagation, persistence, and deletion. TTY
133+
# streaming/environment and CLI exit-code propagation, attachment
134+
# replay/recovery, signals, and no-keep cases remain. Nextest archive
135+
# filters cannot select individual tests, so keep the complete binary in
136+
# the follow-up bucket.
136137
"sandbox_lifecycle"
137138
# Needs a prebuilt musl DNS probe in guest artifact mode; tracked in #3009.
138139
"transparent_tcp"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Driver-agnostic sandbox environment conformance tests.
5+
6+
use openshell_conformance::{OpenShellRunner, SANDBOX_ENVIRONMENT_SCENARIO};
7+
8+
/// Exercise declared environment propagation through the candidate CLI.
9+
#[tokio::test]
10+
async fn declared_environment() {
11+
let mut runner = OpenShellRunner::from_env(SANDBOX_ENVIRONMENT_SCENARIO.name)
12+
.expect("candidate openshell CLI is available");
13+
14+
let result = async {
15+
runner.check_gateway_status().await?;
16+
SANDBOX_ENVIRONMENT_SCENARIO.run(&mut runner).await
17+
}
18+
.await;
19+
if let Err(error) = runner.finish(result).await {
20+
panic!("sandbox environment conformance scenario failed:\n{error}");
21+
}
22+
}

0 commit comments

Comments
 (0)