From a2554c4563aa5694233592b9eb44e91b71caeea0 Mon Sep 17 00:00:00 2001 From: politerealism Date: Wed, 14 Oct 2026 14:06:49 -0400 Subject: [PATCH] test(conformance): migrate workspace lifecycle tests to workspace stories Move the workspace lifecycle tests from the legacy e2e crate into a new `workspaces` conformance area. Workspace create, inspect, scope and delete behavior is not driver-specific, so it belongs with the other conformance stories and runs against any prepared gateway. The original `workspace_full_crud_lifecycle` checked seven behaviors in one test. Split it so a failure points at one behavior: - workspace_full_crud_lifecycle: create, get, list as Active, delete when empty, gone afterwards (name kept so filters keep working) - template_is_scoped_to_its_workspace (new name): a resource is listed in its own workspace and in the all-workspaces view with its owner, and not in the default workspace - workspace_terminating_rejects_creates: name kept. It now also covers the deletion guard, because a refused delete is what moves the workspace to Terminating, which is a public phase The scoped resource is a sandbox template, not a provider. The original used a provider of type `openai`, which needs a provider profile that only the wrapper lanes import. A freshly provisioned tmachine gateway has none, so those stories failed there. A template is workspace-scoped, is covered by the same delete guard, and needs no profile, credential or image. This drops the `provider list --all-workspaces` check; the all-workspaces view is still checked, for templates. Assertions check exit status and listed state from `--output json` instead of matching message text. The CLI exposes no structured error and no structured `workspace get`, so those limits are documented in the area. The paginated listings are searched, so a busy gateway does not hide an entry, and a missing collection in the output is an error. A passing story removes what it created. Cleanup runs only after a failure and reports anything other than "not found". The shared runner does not track workspaces, so this stays in the area helpers. Remove the old test file, its Cargo target and its Podman archive exclusion, and list the area in the conformance README. Contributes to #3954 Signed-off-by: politerealism --- e2e/rust/Cargo.toml | 5 - e2e/rust/tests/workspace_lifecycle.rs | 278 ------------------ e2e/suites/conformance/README.md | 6 +- .../conformance/cli/tests/workspaces/crud.rs | 53 ++++ .../cli/tests/workspaces/helpers.rs | 215 ++++++++++++++ .../conformance/cli/tests/workspaces/main.rs | 21 ++ .../cli/tests/workspaces/resource_scoping.rs | 73 +++++ .../cli/tests/workspaces/terminating.rs | 102 +++++++ tests/artifacts.nix | 1 - 9 files changed, 468 insertions(+), 286 deletions(-) delete mode 100644 e2e/rust/tests/workspace_lifecycle.rs create mode 100644 e2e/suites/conformance/cli/tests/workspaces/crud.rs create mode 100644 e2e/suites/conformance/cli/tests/workspaces/helpers.rs create mode 100644 e2e/suites/conformance/cli/tests/workspaces/main.rs create mode 100644 e2e/suites/conformance/cli/tests/workspaces/resource_scoping.rs create mode 100644 e2e/suites/conformance/cli/tests/workspaces/terminating.rs diff --git a/e2e/rust/Cargo.toml b/e2e/rust/Cargo.toml index 80bbd58f7e..2c7608cbb0 100644 --- a/e2e/rust/Cargo.toml +++ b/e2e/rust/Cargo.toml @@ -209,11 +209,6 @@ name = "forward_proxy_jsonrpc_l7" path = "tests/forward_proxy_jsonrpc_l7.rs" required-features = ["e2e-host-gateway"] -[[test]] -name = "workspace_lifecycle" -path = "tests/workspace_lifecycle.rs" -required-features = ["e2e"] - [[test]] name = "sandbox_templates" path = "tests/sandbox_templates.rs" diff --git a/e2e/rust/tests/workspace_lifecycle.rs b/e2e/rust/tests/workspace_lifecycle.rs deleted file mode 100644 index 2fe946389e..0000000000 --- a/e2e/rust/tests/workspace_lifecycle.rs +++ /dev/null @@ -1,278 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -#![cfg(feature = "e2e")] - -//! E2E tests for workspace resource lifecycle. -//! -//! Covers: -//! - Workspace CRUD -//! - Provider creation scoped to a workspace -//! - Workspace isolation (resources in one workspace are invisible in another) -//! - `--all-workspaces` listing -//! - Deletion guard (workspace cannot be deleted while resources exist) -//! - Successful deletion after resource cleanup - -use std::process::Stdio; - -use openshell_e2e::harness::binary::{openshell_bin, openshell_cmd}; -use openshell_e2e::harness::output::strip_ansi; - -const WORKSPACE: &str = "lifecycle-test"; -const WORKSPACE_TERM: &str = "terminating-test"; -const PROVIDER: &str = "lifecycle-prov"; -const PROVIDER_TERM: &str = "term-prov"; - -struct CliResult { - output: String, - success: bool, -} - -async fn run_cli(args: &[&str]) -> CliResult { - let mut cmd = openshell_cmd(); - cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); - - let output = cmd.output().await.expect("spawn openshell command"); - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - let combined = format!("{stdout}{stderr}"); - - CliResult { - output: strip_ansi(&combined), - success: output.status.success(), - } -} - -struct WorkspaceCleanup; - -impl Drop for WorkspaceCleanup { - fn drop(&mut self) { - let bin = openshell_bin(); - let _ = std::process::Command::new(&bin) - .args(["provider", "delete", PROVIDER, "--workspace", WORKSPACE]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - let _ = std::process::Command::new(&bin) - .args(["workspace", "delete", WORKSPACE]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - } -} - -#[tokio::test] -async fn workspace_full_crud_lifecycle() { - let _cleanup = WorkspaceCleanup; - - // 1. Create workspace. - let res = run_cli(&["workspace", "create", "--name", WORKSPACE]).await; - assert!(res.success, "workspace create failed: {}", res.output); - - // 2. Verify workspace exists. - let res = run_cli(&["workspace", "get", WORKSPACE]).await; - assert!(res.success, "workspace get failed: {}", res.output); - assert!( - res.output.contains(WORKSPACE), - "workspace get output should contain workspace name: {}", - res.output - ); - - // 3. Create provider in the workspace. - let res = run_cli(&[ - "provider", - "create", - "--name", - PROVIDER, - "--type", - "openai", - "--credential", - "OPENAI_API_KEY=test-value", - "--workspace", - WORKSPACE, - ]) - .await; - assert!( - res.success, - "provider create in workspace failed: {}", - res.output - ); - - // 4. List providers in workspace — should see our provider. - let res = run_cli(&["provider", "list", "--workspace", WORKSPACE]).await; - assert!(res.success, "provider list failed: {}", res.output); - assert!( - res.output.contains(PROVIDER), - "workspace-scoped list should show the provider: {}", - res.output - ); - - // 5. List providers in default workspace — should NOT see our provider. - let res = run_cli(&["provider", "list"]).await; - assert!( - res.success, - "provider list (default) failed: {}", - res.output - ); - assert!( - !res.output.contains(PROVIDER), - "default workspace should not contain the workspace-scoped provider: {}", - res.output - ); - - // 6. List providers with --all-workspaces — should see our provider. - let res = run_cli(&["provider", "list", "--all-workspaces"]).await; - assert!( - res.success, - "provider list --all-workspaces failed: {}", - res.output - ); - assert!( - res.output.contains(PROVIDER), - "--all-workspaces should include the workspace-scoped provider: {}", - res.output - ); - - // 7. Attempt workspace deletion — should fail because provider exists. - let res = run_cli(&["workspace", "delete", WORKSPACE]).await; - assert!( - !res.success, - "workspace delete should fail while resources exist: {}", - res.output - ); - assert!( - res.output.contains("still contains resources"), - "error should mention blocking resources: {}", - res.output - ); - - // 8. Delete the provider. - let res = run_cli(&["provider", "delete", PROVIDER, "--workspace", WORKSPACE]).await; - assert!(res.success, "provider delete failed: {}", res.output); - - // 9. Workspace deletion should now succeed. - let res = run_cli(&["workspace", "delete", WORKSPACE]).await; - assert!( - res.success, - "workspace delete should succeed after resources removed: {}", - res.output - ); - - // 10. Verify workspace is gone. - let res = run_cli(&["workspace", "get", WORKSPACE]).await; - assert!( - !res.success, - "workspace get should fail after deletion: {}", - res.output - ); -} - -struct TerminatingCleanup; - -impl Drop for TerminatingCleanup { - fn drop(&mut self) { - let bin = openshell_bin(); - let _ = std::process::Command::new(&bin) - .args([ - "provider", - "delete", - PROVIDER_TERM, - "--workspace", - WORKSPACE_TERM, - ]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - let _ = std::process::Command::new(&bin) - .args(["workspace", "delete", WORKSPACE_TERM]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - } -} - -#[tokio::test] -async fn workspace_terminating_rejects_creates() { - let _cleanup = TerminatingCleanup; - - // 1. Create workspace. - let res = run_cli(&["workspace", "create", "--name", WORKSPACE_TERM]).await; - assert!(res.success, "workspace create failed: {}", res.output); - - // 2. Create a provider to block deletion. - let res = run_cli(&[ - "provider", - "create", - "--name", - PROVIDER_TERM, - "--type", - "openai", - "--credential", - "OPENAI_API_KEY=test-value", - "--workspace", - WORKSPACE_TERM, - ]) - .await; - assert!(res.success, "provider create failed: {}", res.output); - - // 3. Attempt deletion — fails, but workspace is now Terminating. - let res = run_cli(&["workspace", "delete", WORKSPACE_TERM]).await; - assert!( - !res.success, - "delete should fail with blocker: {}", - res.output - ); - - // 4. Workspace list should show Terminating status. - let res = run_cli(&["workspace", "list"]).await; - assert!(res.success, "workspace list failed: {}", res.output); - assert!( - res.output.contains("Terminating"), - "workspace list should show Terminating status: {}", - res.output - ); - - // 5. Creating a new provider should be rejected. - let res = run_cli(&[ - "provider", - "create", - "--name", - "should-fail", - "--type", - "openai", - "--credential", - "OPENAI_API_KEY=test-value", - "--workspace", - WORKSPACE_TERM, - ]) - .await; - assert!( - !res.success, - "provider create should fail in terminating workspace: {}", - res.output - ); - assert!( - res.output.contains("being deleted"), - "error should mention workspace is being deleted: {}", - res.output - ); - - // 6. Delete the blocking provider (deletes still work in Terminating). - let res = run_cli(&[ - "provider", - "delete", - PROVIDER_TERM, - "--workspace", - WORKSPACE_TERM, - ]) - .await; - assert!(res.success, "provider delete failed: {}", res.output); - - // 7. Retry deletion — should succeed (idempotent on already-Terminating). - let res = run_cli(&["workspace", "delete", WORKSPACE_TERM]).await; - assert!( - res.success, - "workspace delete should succeed after cleanup: {}", - res.output - ); -} diff --git a/e2e/suites/conformance/README.md b/e2e/suites/conformance/README.md index e0ecece555..8a492223bc 100644 --- a/e2e/suites/conformance/README.md +++ b/e2e/suites/conformance/README.md @@ -10,7 +10,8 @@ conformance area and user story. The CLI is the current client interface; lifecy - `cli/tests//.rs`: test functions, steps, and assertions for a story. - `cli/tests//helpers.rs`: setup and assertions shared by that area's stories. -The current areas are `file_transfer`, `lifecycle`, `policy_advisor`, and `smoke`. +The current areas are `file_transfer`, `lifecycle`, `policy_advisor`, `smoke`, and +`workspaces`. Each area compiles into one test binary; individual stories remain selectable by their module prefix. `main.rs` contains only module declarations. @@ -66,7 +67,8 @@ must supply the workload image and tools used by the selected scenarios. Missing CLI or gateway prerequisites fail the run rather than silently passing. Run one group with `--test file_transfer`, `--test lifecycle`, -`--test policy_advisor`, or `--test smoke`, before the `--` separator. A group or +`--test policy_advisor`, `--test smoke`, or `--test workspaces`, before the `--` +separator. A group or individual-test filter exercises only part of the suite. The tmachine conformance testsuite runs these same entry points from the diff --git a/e2e/suites/conformance/cli/tests/workspaces/crud.rs b/e2e/suites/conformance/cli/tests/workspaces/crud.rs new file mode 100644 index 0000000000..57d1d22848 --- /dev/null +++ b/e2e/suites/conformance/cli/tests/workspaces/crud.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::helpers::{fails, finish, require_status, succeeds}; +use openshell_e2e_support::OpenShellRunner; + +/// Verify a workspace can be created, inspected, listed as Active, deleted when +/// empty, and is gone afterwards. +#[tokio::test] +async fn workspace_full_crud_lifecycle() { + let mut runner = + OpenShellRunner::from_env("workspaces/crud").expect("candidate openshell CLI is available"); + let workspace = format!("ws-{}-cr", runner.id()); + let result = async { + runner.check_gateway_status().await?; + let r = &runner; + let name = workspace.as_str(); + succeeds( + r, + "create", + "workspace is created", + &["workspace", "create", "--name", name], + ) + .await?; + succeeds( + r, + "get", + "workspace can be retrieved", + &["workspace", "get", name], + ) + .await?; + require_status(r, "list-active", name, Some("Active")).await?; + succeeds( + r, + "delete", + "empty workspace is deleted", + &["workspace", "delete", name], + ) + .await?; + fails( + r, + "get-after-delete", + "deleted workspace is gone", + &["workspace", "get", name], + ) + .await?; + require_status(r, "list-absent", name, None).await + } + .await; + if let Err(error) = finish(runner, result, &workspace, &[]).await { + panic!("workspaces/crud conformance story failed:\n{error}"); + } +} diff --git a/e2e/suites/conformance/cli/tests/workspaces/helpers.rs b/e2e/suites/conformance/cli/tests/workspaces/helpers.rs new file mode 100644 index 0000000000..ad855ebbbd --- /dev/null +++ b/e2e/suites/conformance/cli/tests/workspaces/helpers.rs @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use openshell_e2e_support::{CommandResult, OpenShellRunner}; +use serde_json::Value; +use std::time::Duration; + +const COMMAND_TIMEOUT: Duration = Duration::from_mins(2); +const MAX_PAGES: u32 = 100; + +/// Run a CLI command and return its result without judging the outcome. +pub async fn run( + runner: &OpenShellRunner, + step: &str, + expectation: &str, + args: &[&str], +) -> Result { + runner + .step(step) + .description(expectation) + .with_timeout(COMMAND_TIMEOUT) + .run(args) + .await + .map_err(|error| error.to_string()) +} + +/// Run a CLI command that must succeed. +pub async fn succeeds( + runner: &OpenShellRunner, + step: &str, + expectation: &str, + args: &[&str], +) -> Result<(), String> { + run(runner, step, expectation, args) + .await? + .require_success() +} + +/// Run a CLI command that must fail. +pub async fn fails( + runner: &OpenShellRunner, + step: &str, + expectation: &str, + args: &[&str], +) -> Result<(), String> { + let result = run(runner, step, expectation, args).await?; + if result.success() { + return Err(result.failure_diagnostic(expectation)); + } + Ok(()) +} + +pub fn create_template_args<'a>(workspace: &'a str, name: &'a str) -> [&'a str; 6] { + [ + "sandbox", + "template", + "create", + name, + "--workspace", + workspace, + ] +} + +/// Find the entry called `name` in a paginated `--output json` listing, where +/// `collection` is the JSON key that holds the entries. +async fn find_entry( + runner: &OpenShellRunner, + step: &str, + list: &[&str], + collection: &str, + name: &str, +) -> Result, String> { + let mut token = String::new(); + for page in 0..MAX_PAGES { + let mut args = list.to_vec(); + args.extend(["--page-token", &token, "--output", "json"]); + let result = run(runner, &format!("{step}/{page}"), "list succeeds", &args).await?; + result.require_success()?; + let listing: Value = result.json().map_err(|error| error.to_string())?; + let Some(entries) = listing[collection].as_array() else { + return Err(format!("{step}: list output has no '{collection}' array")); + }; + if let Some(entry) = entries.iter().find(|entry| entry["name"] == name) { + return Ok(Some(entry.clone())); + } + token = listing["next_page_token"] + .as_str() + .unwrap_or_default() + .to_string(); + if token.is_empty() { + return Ok(None); + } + } + Err(format!( + "{step}: listing did not finish within {MAX_PAGES} pages" + )) +} + +/// Require the workspace's listed status to be `expected`, where `None` means +/// the workspace is not listed. +pub async fn require_status( + runner: &OpenShellRunner, + step: &str, + workspace: &str, + expected: Option<&str>, +) -> Result<(), String> { + let entry = find_entry( + runner, + step, + &["workspace", "list"], + "workspaces", + workspace, + ) + .await?; + let actual = entry.as_ref().and_then(|entry| entry["status"].as_str()); + if actual == expected { + return Ok(()); + } + Err(format!( + "{step}: workspace '{workspace}' is {actual:?}, expected {expected:?}" + )) +} + +/// Return the workspace that owns `template` in the listing selected by `scope` +/// (`--workspace `, `--all-workspaces`, or nothing for the default +/// workspace), or `None` when it is not listed. +pub async fn template_owner( + runner: &OpenShellRunner, + step: &str, + scope: &[&str], + template: &str, +) -> Result, String> { + let mut list = vec!["sandbox", "template", "list"]; + list.extend_from_slice(scope); + let entry = find_entry(runner, step, &list, "templates", template).await?; + Ok(entry.map(|entry| entry["workspace"].as_str().unwrap_or_default().to_string())) +} + +/// Delete a story's templates and workspace. Resources a successful story +/// already removed report "not found", which is fine; any other failure is +/// returned so cleanup problems stay visible. +async fn cleanup_workspace( + runner: &OpenShellRunner, + workspace: &str, + templates: &[&str], +) -> Result<(), String> { + let mut commands: Vec> = templates + .iter() + .map(|template| { + vec![ + "sandbox", + "template", + "delete", + template, + "--workspace", + workspace, + ] + }) + .collect(); + commands.push(vec!["workspace", "delete", workspace]); + let mut failures = Vec::new(); + for args in commands { + match run( + runner, + "cleanup", + "resource is deleted or already absent", + &args, + ) + .await + { + Ok(result) if result.success() || reports_not_found(&result) => {} + Ok(result) => failures.push(result.failure_diagnostic("cleanup delete succeeds")), + Err(error) => failures.push(error), + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(failures.join("\n\n")) + } +} + +fn reports_not_found(result: &CommandResult) -> bool { + let output = format!("{}{}", result.stdout(), result.stderr()); + let output = output.to_ascii_lowercase(); + output.contains("sandbox template not found") + || output.contains("workspace not found") + || (output.contains("workspace '") && output.contains("' not found")) +} + +/// Combine a story result with its cleanup result, keeping both failures. +fn merge(result: Result<(), String>, cleanup: Result<(), String>) -> Result<(), String> { + match (result, cleanup) { + (Err(error), Err(cleanup)) => Err(format!("{error}\n\ncleanup also failed:\n{cleanup}")), + (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } +} + +/// Finish a story. A passing story already removed its resources, so cleanup +/// runs only after a failure; both failures are kept. The runner then cleans up +/// its own tracked resources. +pub async fn finish( + runner: OpenShellRunner, + result: Result<(), String>, + workspace: &str, + templates: &[&str], +) -> Result<(), String> { + let cleanup = if result.is_err() { + cleanup_workspace(&runner, workspace, templates).await + } else { + Ok(()) + }; + runner.finish(merge(result, cleanup)).await +} diff --git a/e2e/suites/conformance/cli/tests/workspaces/main.rs b/e2e/suites/conformance/cli/tests/workspaces/main.rs new file mode 100644 index 0000000000..290cc5902b --- /dev/null +++ b/e2e/suites/conformance/cli/tests/workspaces/main.rs @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Workspace conformance stories. +//! +//! Preconditions: a gateway that lets the test identity create and delete +//! workspaces and sandbox templates. Targets that restrict workspace creation, +//! such as Kubernetes operator-workspace mode, are not covered. Stories use +//! sandbox templates as the workspace-scoped resource because they need no +//! provider profile, credential or image from the target. +//! +//! Side effects: each story creates a workspace and templates named from the +//! run id, and removes them even when it fails. +//! +//! Known limitation: `workspace get` has no structured output, so stories check +//! its exit status and read workspace state from `workspace list --output json`. + +mod crud; +mod helpers; +mod resource_scoping; +mod terminating; diff --git a/e2e/suites/conformance/cli/tests/workspaces/resource_scoping.rs b/e2e/suites/conformance/cli/tests/workspaces/resource_scoping.rs new file mode 100644 index 0000000000..44f5a5c3b9 --- /dev/null +++ b/e2e/suites/conformance/cli/tests/workspaces/resource_scoping.rs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::helpers::{create_template_args, finish, succeeds, template_owner}; +use openshell_e2e_support::OpenShellRunner; + +/// Verify a template created in a workspace is listed in that workspace and in +/// the all-workspaces view with its owner, and is not visible in the default +/// workspace. +#[tokio::test] +async fn template_is_scoped_to_its_workspace() { + let mut runner = OpenShellRunner::from_env("workspaces/resource-scoping") + .expect("candidate openshell CLI is available"); + let workspace = format!("ws-{}-sc", runner.id()); + let template = format!("ct-{}-sc", runner.id()); + let result = async { + runner.check_gateway_status().await?; + let r = &runner; + let (ws, tpl) = (workspace.as_str(), template.as_str()); + succeeds( + r, + "create", + "workspace is created", + &["workspace", "create", "--name", ws], + ) + .await?; + succeeds( + r, + "template-create", + "template is created in the workspace", + &create_template_args(ws, tpl), + ) + .await?; + + let scoped = template_owner(r, "list-scoped", &["--workspace", ws], tpl).await?; + let default = template_owner(r, "list-default", &[], tpl).await?; + let all = template_owner(r, "list-all", &["--all-workspaces"], tpl).await?; + if scoped.as_deref() != Some(ws) { + return Err(format!( + "{tpl} is owned by {scoped:?} in its workspace, not {ws}" + )); + } + if all.as_deref() != Some(ws) { + return Err(format!( + "{tpl} is owned by {all:?} across workspaces, not {ws}" + )); + } + if let Some(owner) = default { + return Err(format!( + "{tpl} is visible in the default workspace as {owner}" + )); + } + + succeeds( + r, + "template-delete", + "template is deleted", + &["sandbox", "template", "delete", tpl, "--workspace", ws], + ) + .await?; + succeeds( + r, + "delete", + "workspace is deleted once empty", + &["workspace", "delete", ws], + ) + .await + } + .await; + if let Err(error) = finish(runner, result, &workspace, &[&template]).await { + panic!("workspaces/resource-scoping conformance story failed:\n{error}"); + } +} diff --git a/e2e/suites/conformance/cli/tests/workspaces/terminating.rs b/e2e/suites/conformance/cli/tests/workspaces/terminating.rs new file mode 100644 index 0000000000..7eb677f2f4 --- /dev/null +++ b/e2e/suites/conformance/cli/tests/workspaces/terminating.rs @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::helpers::{ + create_template_args, fails, finish, require_status, succeeds, template_owner, +}; +use openshell_e2e_support::OpenShellRunner; + +/// Verify the deletion guard and the Terminating phase. Deleting a workspace +/// that still holds a resource is refused and leaves it Terminating; while it +/// is Terminating new resources are rejected and existing ones can still be +/// deleted; retrying the delete then succeeds. +/// +/// Known limitation: the CLI exposes no structured error, so rejections are +/// checked by exit status and by the resulting state, not by message. +#[tokio::test] +async fn workspace_terminating_rejects_creates() { + let mut runner = OpenShellRunner::from_env("workspaces/terminating") + .expect("candidate openshell CLI is available"); + let workspace = format!("ws-{}-tm", runner.id()); + let template = format!("ct-{}-tm", runner.id()); + let blocked = format!("ct-{}-bl", runner.id()); + let result = async { + runner.check_gateway_status().await?; + let r = &runner; + let (ws, tpl, bl) = (workspace.as_str(), template.as_str(), blocked.as_str()); + succeeds( + r, + "create", + "workspace is created", + &["workspace", "create", "--name", ws], + ) + .await?; + succeeds( + r, + "template-create", + "template is created in the workspace", + &create_template_args(ws, tpl), + ) + .await?; + + succeeds( + r, + "control-create", + "the same template can be created while the workspace is Active", + &create_template_args(ws, bl), + ) + .await?; + succeeds( + r, + "control-delete", + "and deleted again", + &["sandbox", "template", "delete", bl, "--workspace", ws], + ) + .await?; + + fails( + r, + "delete-blocked", + "delete is refused while the workspace holds a resource", + &["workspace", "delete", ws], + ) + .await?; + require_status(r, "list-terminating", ws, Some("Terminating")).await?; + + fails( + r, + "create-rejected", + "a new template is rejected while Terminating", + &create_template_args(ws, bl), + ) + .await?; + if template_owner(r, "list-rejected", &["--workspace", ws], bl) + .await? + .is_some() + { + return Err(format!( + "rejected template '{bl}' exists in workspace '{ws}'" + )); + } + + succeeds( + r, + "template-delete", + "an existing template can still be deleted", + &["sandbox", "template", "delete", tpl, "--workspace", ws], + ) + .await?; + succeeds( + r, + "delete-retry", + "retrying the delete succeeds once empty", + &["workspace", "delete", ws], + ) + .await?; + require_status(r, "list-absent", ws, None).await + } + .await; + if let Err(error) = finish(runner, result, &workspace, &[&template, &blocked]).await { + panic!("workspaces/terminating conformance story failed:\n{error}"); + } +} diff --git a/tests/artifacts.nix b/tests/artifacts.nix index 4e0921adb0..71e816909c 100644 --- a/tests/artifacts.nix +++ b/tests/artifacts.nix @@ -131,7 +131,6 @@ let # Needs a prebuilt musl DNS probe in guest artifact mode; tracked in #3009. "transparent_tcp" "websocket_conformance" - "workspace_lifecycle" ]; podmanE2eArchiveFilter = let