Files
OpenShell/e2e/rust/tests/sandbox_labels.rs
Eric Curtin cfcc3733bd fix(e2e): stop sandbox leaks from async Drop cleanup (#3750)
* 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>
2026-09-29 10:10:29 +00:00

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"
);
}