Skip to content

Commit c2ed632

Browse files
committed
feat(cli): add --prune option to sandbox delete command
Add a --prune flag to `openshell sandbox delete` that deletes only inactive sandboxes. This allows users to clean up terminated, stopped, or errored sandboxes while preserving active and provisioning ones. The prune filter targets sandboxes in these phases: - Unspecified - Unknown - Error - Deleting - Stopped - Completed Active sandboxes (Ready, Provisioning, Starting) are preserved. The flag conflicts with both --all and named sandbox arguments, ensuring clear deletion intent. Fixes: #2594 Signed-off-by: Michael Engel <mengel@redhat.com>
1 parent 551a81c commit c2ed632

5 files changed

Lines changed: 131 additions & 9 deletions

File tree

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1617,12 +1617,16 @@ enum SandboxCommands {
16171617
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
16181618
Delete {
16191619
/// Sandbox names.
1620-
#[arg(required_unless_present = "all", num_args = 1.., value_name = "NAME", add = ArgValueCompleter::new(completers::complete_sandbox_names))]
1620+
#[arg(required_unless_present = "all", required_unless_present = "prune", num_args = 1.., value_name = "NAME", add = ArgValueCompleter::new(completers::complete_sandbox_names))]
16211621
names: Vec<String>,
16221622

16231623
/// Delete all sandboxes.
1624-
#[arg(long, conflicts_with = "names")]
1624+
#[arg(long, conflicts_with_all = ["names", "prune"])]
16251625
all: bool,
1626+
1627+
/// Delete all inactive sandboxes.
1628+
#[arg(long, conflicts_with_all = ["names", "all"])]
1629+
prune: bool,
16261630
},
16271631

16281632
/// Stop a sandbox while preserving its workspace.
@@ -3492,11 +3496,12 @@ async fn run_async() -> Result<()> {
34923496
)
34933497
.await?;
34943498
}
3495-
SandboxCommands::Delete { names, all } => {
3499+
SandboxCommands::Delete { names, all, prune } => {
34963500
run::sandbox_delete(
34973501
endpoint,
34983502
&names,
34993503
all,
3504+
prune,
35003505
&cli.workspace,
35013506
&tls,
35023507
&ctx.name,

‎crates/openshell-cli/src/run.rs‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ async fn finalize_sandbox_create_session(
409409
}
410410

411411
let names = [sandbox_name.to_string()];
412-
if let Err(err) = sandbox_delete(server, &names, false, workspace, tls, gateway).await {
412+
if let Err(err) = sandbox_delete(server, &names, false, false, workspace, tls, gateway).await {
413413
if let Ok(exit_code) = session_result.as_ref() {
414414
return Err(miette::miette!(
415415
"sandbox command exited with status {exit_code}, but ephemeral cleanup failed: {err}"
@@ -3424,13 +3424,14 @@ pub async fn sandbox_delete(
34243424
server: &str,
34253425
names: &[String],
34263426
all: bool,
3427+
prune: bool,
34273428
workspace: &str,
34283429
tls: &TlsOptions,
34293430
gateway: &str,
34303431
) -> Result<()> {
34313432
let mut client = grpc_client(server, tls).await?;
34323433

3433-
let names_to_delete: Vec<String> = if all {
3434+
let names_to_delete: Vec<String> = if all || prune {
34343435
let mut page_token = String::new();
34353436
let mut sandboxes = Vec::new();
34363437
loop {
@@ -3444,7 +3445,16 @@ pub async fn sandbox_delete(
34443445
.await
34453446
.into_diagnostic()?
34463447
.into_inner();
3447-
sandboxes.extend(response.sandboxes);
3448+
3449+
if prune {
3450+
sandboxes.extend(response.sandboxes.into_iter().filter(|s| {
3451+
let phase = SandboxPhase::try_from(s.phase()).unwrap_or(SandboxPhase::Unknown);
3452+
[SandboxPhase::Error, SandboxPhase::Completed].contains(&phase)
3453+
}));
3454+
} else {
3455+
sandboxes.extend(response.sandboxes);
3456+
}
3457+
34483458
if response.next_page_token.is_empty() {
34493459
break;
34503460
}

‎crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs‎

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ struct SandboxState {
8383
template_get_requests: Arc<Mutex<Vec<GetSandboxTemplateRequest>>>,
8484
template_list_requests: Arc<Mutex<Vec<ListSandboxTemplatesRequest>>>,
8585
template_delete_requests: Arc<Mutex<Vec<DeleteSandboxTemplateRequest>>>,
86+
sandboxes: Arc<Mutex<Vec<Sandbox>>>,
8687
}
8788

8889
#[derive(Clone, Default)]
@@ -245,7 +246,10 @@ impl OpenShell for TestOpenShell {
245246
&self,
246247
_request: tonic::Request<ListSandboxesRequest>,
247248
) -> Result<Response<ListSandboxesResponse>, Status> {
248-
Ok(Response::new(ListSandboxesResponse::default()))
249+
Ok(Response::new(ListSandboxesResponse {
250+
sandboxes: self.state.sandboxes.lock().await.clone(),
251+
next_page_token: String::new(),
252+
}))
249253
}
250254

251255
async fn create_sandbox_template(
@@ -1564,6 +1568,24 @@ async fn add_provider(server: &TestServer, name: &str, provider_type: &str) {
15641568
});
15651569
}
15661570

1571+
async fn add_sandbox(server: &TestServer, name: &str, phase: SandboxPhase) {
1572+
let mut sandbox = Sandbox {
1573+
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
1574+
id: format!("sandbox-{name}"),
1575+
name: name.to_string(),
1576+
created_time: None,
1577+
labels: HashMap::new(),
1578+
resource_version: 0,
1579+
annotations: HashMap::new(),
1580+
workspace: String::new(),
1581+
deletion_time: None,
1582+
}),
1583+
..Sandbox::default()
1584+
};
1585+
sandbox.set_phase(phase as i32);
1586+
server.openshell.state.sandboxes.lock().await.push(sandbox);
1587+
}
1588+
15671589
fn test_tls(server: &TestServer) -> TlsOptions {
15681590
server.tls.with_gateway_name("openshell")
15691591
}
@@ -1600,6 +1622,7 @@ async fn sandbox_delete_continues_after_entry_failure() {
16001622
&server.endpoint,
16011623
&["failing-sandbox".to_string(), "later-sandbox".to_string()],
16021624
false,
1625+
false,
16031626
"default",
16041627
&tls,
16051628
"openshell",
@@ -1665,6 +1688,89 @@ async fn sandbox_create_tolerates_an_unreachable_profile_catalog() {
16651688
);
16661689
}
16671690

1691+
#[tokio::test]
1692+
async fn sandbox_delete_all() {
1693+
let server = run_server().await;
1694+
let tls = test_tls(&server);
1695+
1696+
add_sandbox(&server, "unspecified", SandboxPhase::Unspecified).await;
1697+
add_sandbox(&server, "provisioning", SandboxPhase::Provisioning).await;
1698+
add_sandbox(&server, "ready", SandboxPhase::Ready).await;
1699+
add_sandbox(&server, "error", SandboxPhase::Error).await;
1700+
add_sandbox(&server, "deleting", SandboxPhase::Deleting).await;
1701+
add_sandbox(&server, "stopping", SandboxPhase::Stopping).await;
1702+
add_sandbox(&server, "stopped", SandboxPhase::Stopped).await;
1703+
add_sandbox(&server, "starting", SandboxPhase::Starting).await;
1704+
add_sandbox(&server, "completed", SandboxPhase::Completed).await;
1705+
add_sandbox(&server, "unknown", SandboxPhase::Unknown).await;
1706+
1707+
assert!(
1708+
run::sandbox_delete(
1709+
&server.endpoint,
1710+
&[],
1711+
true,
1712+
false,
1713+
"default",
1714+
&tls,
1715+
"openshell"
1716+
)
1717+
.await
1718+
.is_ok()
1719+
);
1720+
1721+
assert_eq!(
1722+
deleted_names(&server).await,
1723+
vec![
1724+
vec!["unspecified".to_string()],
1725+
vec!["provisioning".to_string()],
1726+
vec!["ready".to_string()],
1727+
vec!["error".to_string()],
1728+
vec!["deleting".to_string()],
1729+
vec!["stopping".to_string()],
1730+
vec!["stopped".to_string()],
1731+
vec!["starting".to_string()],
1732+
vec!["completed".to_string()],
1733+
vec!["unknown".to_string()],
1734+
]
1735+
);
1736+
}
1737+
1738+
#[tokio::test]
1739+
async fn sandbox_delete_prune() {
1740+
let server = run_server().await;
1741+
let tls = test_tls(&server);
1742+
1743+
add_sandbox(&server, "unspecified", SandboxPhase::Unspecified).await;
1744+
add_sandbox(&server, "provisioning", SandboxPhase::Provisioning).await;
1745+
add_sandbox(&server, "ready", SandboxPhase::Ready).await;
1746+
add_sandbox(&server, "error", SandboxPhase::Error).await;
1747+
add_sandbox(&server, "deleting", SandboxPhase::Deleting).await;
1748+
add_sandbox(&server, "stopping", SandboxPhase::Stopping).await;
1749+
add_sandbox(&server, "stopped", SandboxPhase::Stopped).await;
1750+
add_sandbox(&server, "starting", SandboxPhase::Starting).await;
1751+
add_sandbox(&server, "completed", SandboxPhase::Completed).await;
1752+
add_sandbox(&server, "unknown", SandboxPhase::Unknown).await;
1753+
1754+
assert!(
1755+
run::sandbox_delete(
1756+
&server.endpoint,
1757+
&[],
1758+
false,
1759+
true,
1760+
"default",
1761+
&tls,
1762+
"openshell"
1763+
)
1764+
.await
1765+
.is_ok()
1766+
);
1767+
1768+
assert_eq!(
1769+
deleted_names(&server).await,
1770+
vec![vec!["error".to_string()], vec!["completed".to_string()],]
1771+
);
1772+
}
1773+
16681774
#[tokio::test]
16691775
async fn sandbox_create_keeps_command_sessions_by_default() {
16701776
let server = run_server().await;

‎deploy/man/openshell.1.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ development task, or behind a cloud reverse proxy.
6565
**sandbox get** *NAME*
6666
: Show details for a sandbox.
6767

68-
**sandbox delete** *NAME* \| **--all**
69-
: Delete one or all sandboxes.
68+
**sandbox delete** *NAME* \| **--prune** \| **--all**
69+
: Delete one, all inactive or all sandboxes.
7070

7171
**sandbox connect** *NAME* \[**--editor** *EDITOR*\]
7272
: SSH into a running sandbox.

‎skills/openshell-cli/SKILL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,7 @@ openshell logs my-sandbox --since 5m
423423
openshell sandbox delete my-sandbox
424424
openshell sandbox delete sandbox-1 sandbox-2 sandbox-3 # Multiple at once
425425
openshell sandbox delete --all
426+
openshell sandbox delete --prune # Only inactive
426427
```
427428

428429
`deletion accepted` means cleanup is still pending. Inspect the sandbox until

0 commit comments

Comments
 (0)