mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-20 06:00:06 +08:00
* fix(e2e): stop sandbox leaks from async Drop cleanup Closes #2922 SandboxGuard::Drop spawned a detached thread to delete the sandbox. The thread got killed with the test process before the delete finished. Switch to a blocking command in Drop, like ManagedCleanup already does. Also wrap two tests' manual cleanup in RAII guards so a panic does not leak a sandbox. Signed-off-by: Eric Curtin <eric.curtin@docker.com> * test(e2e): arm sandbox guards before create Address review: install guards with explicit names first. Signed-off-by: Eric Curtin <eric.curtin@docker.com> --------- Signed-off-by: Eric Curtin <eric.curtin@docker.com>
231 lines
8.0 KiB
Rust
231 lines
8.0 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
use std::process::Stdio;
|
|
|
|
use openshell_e2e::harness::binary::openshell_cmd;
|
|
use openshell_e2e::harness::output::{extract_field, strip_ansi};
|
|
use openshell_e2e::harness::sandbox::SandboxGuard;
|
|
|
|
fn normalize_output(output: &str) -> String {
|
|
let stripped = strip_ansi(output).replace('\r', "");
|
|
let mut cleaned = String::with_capacity(stripped.len());
|
|
|
|
for ch in stripped.chars() {
|
|
match ch {
|
|
'\u{8}' => {
|
|
cleaned.pop();
|
|
}
|
|
'\u{4}' => {}
|
|
_ => cleaned.push(ch),
|
|
}
|
|
}
|
|
|
|
cleaned
|
|
}
|
|
|
|
fn extract_sandbox_name(output: &str) -> Option<String> {
|
|
if let Some((_, rest)) = output.split_once("Created sandbox:") {
|
|
return rest.split_whitespace().next().map(ToOwned::to_owned);
|
|
}
|
|
|
|
extract_field(output, "Created sandbox").or_else(|| extract_field(output, "Name"))
|
|
}
|
|
|
|
async fn create_sandbox_with_labels(name: &str, labels: &[(&str, &str)]) -> String {
|
|
let mut cmd = openshell_cmd();
|
|
cmd.args(["sandbox", "create", "--detach", "--name", name]);
|
|
|
|
for (key, value) in labels {
|
|
cmd.arg("--label").arg(format!("{key}={value}"));
|
|
}
|
|
|
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
|
|
|
let output = cmd.output().await.expect("spawn openshell sandbox create");
|
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
|
let combined = normalize_output(&format!("{stdout}{stderr}"));
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"sandbox create should succeed (exit {:?}):\n{combined}",
|
|
output.status.code()
|
|
);
|
|
|
|
extract_sandbox_name(&combined).expect("sandbox name should be present in output")
|
|
}
|
|
|
|
async fn list_sandboxes_with_selector(selector: &str) -> Vec<String> {
|
|
let mut cmd = openshell_cmd();
|
|
cmd.args(["sandbox", "list", "--names"]);
|
|
|
|
if !selector.is_empty() {
|
|
cmd.arg("--selector").arg(selector);
|
|
}
|
|
|
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
|
|
|
let output = cmd.output().await.expect("spawn openshell sandbox list");
|
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
|
let combined = normalize_output(&format!("{stdout}{stderr}"));
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"sandbox list should succeed (exit {:?}):\n{combined}",
|
|
output.status.code()
|
|
);
|
|
|
|
combined
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
.collect()
|
|
}
|
|
|
|
async fn get_sandbox_details(name: &str) -> String {
|
|
let mut cmd = openshell_cmd();
|
|
cmd.args(["sandbox", "get", name])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
|
|
let output = cmd.output().await.expect("spawn openshell sandbox get");
|
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
|
let combined = normalize_output(&format!("{stdout}{stderr}"));
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"sandbox get should succeed (exit {:?}):\n{combined}",
|
|
output.status.code()
|
|
);
|
|
|
|
combined
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[allow(clippy::too_many_lines)] // end-to-end test exercises full label lifecycle
|
|
async fn sandbox_labels_are_stored_and_filterable() {
|
|
// Keep the unique names below the public 19-character routable-name
|
|
// limit. Hex keeps the per-process suffix compact on busy CI hosts.
|
|
let suffix = format!("{:x}", std::process::id());
|
|
let dev_backend = format!("lbl-db-{suffix}");
|
|
let staging_backend = format!("lbl-sb-{suffix}");
|
|
let prod_frontend = format!("lbl-pf-{suffix}");
|
|
let dev_data = format!("lbl-dd-{suffix}");
|
|
|
|
// Arm guards before create so a partial create still cleans up.
|
|
let _cleanup: Vec<_> = [&dev_backend, &staging_backend, &prod_frontend, &dev_data]
|
|
.into_iter()
|
|
.map(|name| SandboxGuard::manage_existing(name.clone()))
|
|
.collect();
|
|
|
|
// Create sandboxes with different labels
|
|
let name1 =
|
|
create_sandbox_with_labels(&dev_backend, &[("env", "dev"), ("team", "backend")]).await;
|
|
|
|
let name2 =
|
|
create_sandbox_with_labels(&staging_backend, &[("env", "staging"), ("team", "backend")])
|
|
.await;
|
|
|
|
let name3 =
|
|
create_sandbox_with_labels(&prod_frontend, &[("env", "prod"), ("team", "frontend")]).await;
|
|
|
|
let name4 = create_sandbox_with_labels(&dev_data, &[("env", "dev"), ("team", "data")]).await;
|
|
|
|
// Test 1: Verify labels are stored in sandbox metadata
|
|
let details = get_sandbox_details(&name1).await;
|
|
assert!(
|
|
details.contains("env: dev"),
|
|
"sandbox should have env=dev label in details:\n{details}"
|
|
);
|
|
assert!(
|
|
details.contains("team: backend"),
|
|
"sandbox should have team=backend label in details:\n{details}"
|
|
);
|
|
|
|
// Test 2: Filter by single label (env=dev)
|
|
let dev_sandboxes = list_sandboxes_with_selector("env=dev").await;
|
|
assert!(
|
|
dev_sandboxes.contains(&name1),
|
|
"env=dev filter should include {name1}, got: {dev_sandboxes:?}"
|
|
);
|
|
assert!(
|
|
dev_sandboxes.contains(&name4),
|
|
"env=dev filter should include {name4}, got: {dev_sandboxes:?}"
|
|
);
|
|
assert!(
|
|
!dev_sandboxes.contains(&name2),
|
|
"env=dev filter should not include staging sandbox {name2}, got: {dev_sandboxes:?}"
|
|
);
|
|
assert!(
|
|
!dev_sandboxes.contains(&name3),
|
|
"env=dev filter should not include prod sandbox {name3}, got: {dev_sandboxes:?}"
|
|
);
|
|
|
|
// Test 3: Filter by single label (team=backend)
|
|
let backend_sandboxes = list_sandboxes_with_selector("team=backend").await;
|
|
assert!(
|
|
backend_sandboxes.contains(&name1),
|
|
"team=backend filter should include {name1}, got: {backend_sandboxes:?}"
|
|
);
|
|
assert!(
|
|
backend_sandboxes.contains(&name2),
|
|
"team=backend filter should include {name2}, got: {backend_sandboxes:?}"
|
|
);
|
|
assert!(
|
|
!backend_sandboxes.contains(&name3),
|
|
"team=backend filter should not include frontend sandbox {name3}, got: {backend_sandboxes:?}"
|
|
);
|
|
assert!(
|
|
!backend_sandboxes.contains(&name4),
|
|
"team=backend filter should not include data sandbox {name4}, got: {backend_sandboxes:?}"
|
|
);
|
|
|
|
// Test 4: Filter by multiple labels (AND logic: env=dev,team=backend)
|
|
let dev_backend_sandboxes = list_sandboxes_with_selector("env=dev,team=backend").await;
|
|
assert_eq!(
|
|
dev_backend_sandboxes
|
|
.iter()
|
|
.filter(|name| [&name1, &name2, &name3, &name4].contains(name))
|
|
.count(),
|
|
1,
|
|
"env=dev,team=backend filter should return exactly 1 sandbox, got: {dev_backend_sandboxes:?}"
|
|
);
|
|
assert!(
|
|
dev_backend_sandboxes.contains(&name1),
|
|
"env=dev,team=backend filter should include {name1}, got: {dev_backend_sandboxes:?}"
|
|
);
|
|
|
|
// Test 5: Filter by non-existent label value
|
|
let qa_sandboxes = list_sandboxes_with_selector("team=qa").await;
|
|
assert!(
|
|
!qa_sandboxes.contains(&name1)
|
|
&& !qa_sandboxes.contains(&name2)
|
|
&& !qa_sandboxes.contains(&name3)
|
|
&& !qa_sandboxes.contains(&name4),
|
|
"team=qa filter should not return any of our test sandboxes, got: {qa_sandboxes:?}"
|
|
);
|
|
|
|
// Test 6: List all sandboxes (no filter)
|
|
let all_sandboxes = list_sandboxes_with_selector("").await;
|
|
assert!(
|
|
all_sandboxes.contains(&name1),
|
|
"list without filter should include all test sandboxes"
|
|
);
|
|
assert!(
|
|
all_sandboxes.contains(&name2),
|
|
"list without filter should include all test sandboxes"
|
|
);
|
|
assert!(
|
|
all_sandboxes.contains(&name3),
|
|
"list without filter should include all test sandboxes"
|
|
);
|
|
assert!(
|
|
all_sandboxes.contains(&name4),
|
|
"list without filter should include all test sandboxes"
|
|
);
|
|
}
|