Skip to content

Commit 3bfbb49

Browse files
committed
test(conformance): add sandbox environment capability
Signed-off-by: Evan Lezar <elezar@nvidia.com>
1 parent 6e24dc8 commit 3bfbb49

8 files changed

Lines changed: 299 additions & 41 deletions

File tree

‎crates/openshell-conformance-cli/src/main.rs‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ mod tests {
236236
#[test]
237237
fn selects_all_scenarios_by_default() {
238238
let selected = select_scenarios(&[]).expect("select all");
239-
assert_eq!(selected.len(), 11);
239+
assert_eq!(selected.len(), 12);
240240
assert_eq!(selected.len(), scenarios().len());
241241
}
242242

@@ -275,6 +275,17 @@ mod tests {
275275
);
276276
}
277277

278+
#[test]
279+
fn expands_environment_group_to_declared_environment_capability() {
280+
let selected = select_scenarios(&["sandbox-environment".to_string()])
281+
.expect("select environment group");
282+
let names = selected
283+
.iter()
284+
.map(|candidate| candidate.name)
285+
.collect::<Vec<_>>();
286+
assert_eq!(names, ["sandbox-environment/declared"]);
287+
}
288+
278289
#[test]
279290
fn overlapping_selectors_do_not_run_a_leaf_twice() {
280291
let selected = select_scenarios(&[

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use self::executor::{CliExecutionError, CliExecutor, ProcessCli};
2626
pub use scenarios::{
2727
FILE_TRANSFER_GIT_FILTERING_SCENARIO, FILE_TRANSFER_PATH_SAFETY_SCENARIO,
2828
FILE_TRANSFER_ROUND_TRIP_SCENARIO, MECHANISTIC_PROPOSAL_SCENARIO,
29-
NEW_HOSTNAME_PROPOSAL_SCENARIO, SANDBOX_CANONICAL_MAIN_SCENARIO,
29+
NEW_HOSTNAME_PROPOSAL_SCENARIO, SANDBOX_CANONICAL_MAIN_SCENARIO, SANDBOX_ENVIRONMENT_SCENARIO,
3030
SANDBOX_LIFECYCLE_CONTROL_PLANE_SCENARIO, SANDBOX_LIFECYCLE_RESTART_PERSISTENCE_SCENARIO,
3131
SANDBOX_LOCAL_SCENARIO, SMOKE_CONTROL_PLANE_SCENARIO, SMOKE_EXEC_SCENARIO,
3232
};
@@ -53,6 +53,7 @@ const SCENARIOS: &[Scenario] = &[
5353
SANDBOX_LIFECYCLE_CONTROL_PLANE_SCENARIO,
5454
SANDBOX_LIFECYCLE_RESTART_PERSISTENCE_SCENARIO,
5555
SANDBOX_CANONICAL_MAIN_SCENARIO,
56+
SANDBOX_ENVIRONMENT_SCENARIO,
5657
FILE_TRANSFER_ROUND_TRIP_SCENARIO,
5758
FILE_TRANSFER_GIT_FILTERING_SCENARIO,
5859
FILE_TRANSFER_PATH_SAFETY_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, SANDBOX_LOCAL_SCENARIO,
1718
};
19+
pub use sandbox_environment::SANDBOX_ENVIRONMENT_SCENARIO;
1820
pub use sandbox_lifecycle::{
1921
SANDBOX_CANONICAL_MAIN_SCENARIO, SANDBOX_LIFECYCLE_CONTROL_PLANE_SCENARIO,
2022
SANDBOX_LIFECYCLE_RESTART_PERSISTENCE_SCENARIO,
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/declared",
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
@@ -757,41 +757,41 @@ async fn canonical_main_nonzero_exit_preserves_status() {
757757

758758
#[tokio::test]
759759
#[serial(sandbox_lifecycle)]
760-
async fn canonical_main_and_exec_receive_declared_environment() {
761-
for mode in ["--tty", "--no-tty"] {
762-
let script = r#"printf 'declared_env=%s\n' "${REPRO_SENTINEL:-missing}"; while true; do sleep 1; done"#;
763-
let mut sandbox = SandboxGuard::create_keep_with_args(
764-
&[
765-
mode,
766-
"--no-auto-providers",
767-
"--env",
768-
"REPRO_SENTINEL=present",
769-
],
770-
&["sh", "-c", script],
771-
"declared_env=",
772-
)
760+
async fn canonical_tty_main_and_exec_receive_declared_environment() {
761+
let script =
762+
r#"printf 'declared_env=%s\n' "${REPRO_SENTINEL:-missing}"; while true; do sleep 1; done"#;
763+
let mut sandbox = SandboxGuard::create_keep_with_args(
764+
&[
765+
"--tty",
766+
"--no-auto-providers",
767+
"--env",
768+
"REPRO_SENTINEL=present",
769+
],
770+
&["sh", "-c", script],
771+
"declared_env=",
772+
)
773+
.await
774+
.expect("create canonical TTY process with declared environment");
775+
let initial = normalize_output(&sandbox.create_output);
776+
let later = sandbox
777+
.exec(&[
778+
"sh",
779+
"-c",
780+
r#"printf 'declared_env=%s\n' "${REPRO_SENTINEL:-missing}""#,
781+
])
773782
.await
774-
.expect("create canonical process with declared environment");
775-
let initial = normalize_output(&sandbox.create_output);
776-
let later = sandbox
777-
.exec(&[
778-
"sh",
779-
"-c",
780-
r#"printf 'declared_env=%s\n' "${REPRO_SENTINEL:-missing}""#,
781-
])
782-
.await;
783-
sandbox.cleanup().await;
783+
.expect("exec environment probe");
784+
sandbox.cleanup().await;
784785

785-
assert!(
786-
initial.lines().any(|line| line == "declared_env=present"),
787-
"initial process must receive declared environment ({mode}): {initial}"
788-
);
789-
let later = normalize_output(&later.expect("exec environment probe"));
790-
assert!(
791-
later.lines().any(|line| line == "declared_env=present"),
792-
"exec must receive the same declared environment ({mode}): {later}"
793-
);
794-
}
786+
assert!(
787+
initial.lines().any(|line| line == "declared_env=present"),
788+
"initial TTY process must receive declared environment: {initial}"
789+
);
790+
let later = normalize_output(&later);
791+
assert!(
792+
later.lines().any(|line| line == "declared_env=present"),
793+
"exec must receive the same declared environment as the TTY main: {later}"
794+
);
795795
}
796796

797797
#[tokio::test]

‎tests/artifacts.nix‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,11 @@ let
125125
"proxy_egress_pipeline"
126126
# Conformance covers stop/start workspace preservation, deletion while
127127
# stopped, and detached canonical-main terminal state, fast-exit
128-
# classification, persistence, and deletion. TTY streaming and CLI
129-
# exit-code propagation, environment, attachment replay/recovery, signals,
130-
# and no-keep cases remain. Nextest archive filters cannot select individual
131-
# tests, so keep the complete binary in the follow-up bucket.
128+
# classification, environment propagation, persistence, and deletion. TTY
129+
# streaming/environment and CLI exit-code propagation, attachment
130+
# replay/recovery, signals, and no-keep cases remain. Nextest archive
131+
# filters cannot select individual tests, so keep the complete binary in
132+
# the follow-up bucket.
132133
"sandbox_lifecycle"
133134
# Needs a prebuilt musl DNS probe in guest artifact mode; tracked in #3009.
134135
"transparent_tcp"

‎tests/suites/conformance/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ In particular:
2525
and requires `sandbox exec`.
2626
- `lifecycle::canonical_main` covers successful and failing canonical-main
2727
terminal states, persistent status, and deletion.
28-
- Future environment coverage should use a separate leaf when declared-
29-
environment behavior has a distinct runtime requirement.
28+
- `environment::declared_environment` covers declared-environment propagation
29+
to canonical-main and exec processes.
3030
- `policy-advisor/mechanistic-proposal`,
3131
`policy-advisor/new-hostname-proposal`, and `policy-advisor/sandbox-local` have
3232
additional runtime requirements documented in their source module.

0 commit comments

Comments
 (0)