// 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}; 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 { 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 { 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 } async fn delete_sandbox(name: &str) { let mut cmd = openshell_cmd(); cmd.args(["sandbox", "delete", name]) .stdout(Stdio::null()) .stderr(Stdio::null()); let _ = cmd.status().await; } #[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}"); // 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" ); // Cleanup delete_sandbox(&name1).await; delete_sandbox(&name2).await; delete_sandbox(&name3).await; delete_sandbox(&name4).await; }