Skip to content

Commit 9f33ea4

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 5acaaba commit 9f33ea4

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
@@ -1641,12 +1641,16 @@ enum SandboxCommands {
16411641
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
16421642
Delete {
16431643
/// Sandbox names.
1644-
#[arg(required_unless_present = "all", num_args = 1.., value_name = "NAME", add = ArgValueCompleter::new(completers::complete_sandbox_names))]
1644+
#[arg(required_unless_present = "all", required_unless_present = "prune", num_args = 1.., value_name = "NAME", add = ArgValueCompleter::new(completers::complete_sandbox_names))]
16451645
names: Vec<String>,
16461646

16471647
/// Delete all sandboxes.
1648-
#[arg(long, conflicts_with = "names")]
1648+
#[arg(long, conflicts_with_all = ["names", "prune"])]
16491649
all: bool,
1650+
1651+
/// Delete all inactive sandboxes.
1652+
#[arg(long, conflicts_with_all = ["names", "all"])]
1653+
prune: bool,
16501654
},
16511655

16521656
/// Stop a sandbox while preserving its workspace.
@@ -3527,11 +3531,12 @@ async fn run_async() -> Result<()> {
35273531
)
35283532
.await?;
35293533
}
3530-
SandboxCommands::Delete { names, all } => {
3534+
SandboxCommands::Delete { names, all, prune } => {
35313535
run::sandbox_delete(
35323536
endpoint,
35333537
&names,
35343538
all,
3539+
prune,
35353540
&cli.workspace,
35363541
&tls,
35373542
&ctx.name,

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

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

412412
let names = [sandbox_name.to_string()];
413-
if let Err(err) = sandbox_delete(server, &names, false, workspace, tls, gateway).await {
413+
if let Err(err) = sandbox_delete(server, &names, false, false, workspace, tls, gateway).await {
414414
if let Ok(exit_code) = session_result.as_ref() {
415415
return Err(miette::miette!(
416416
"sandbox command exited with status {exit_code}, but ephemeral cleanup failed: {err}"
@@ -3584,13 +3584,14 @@ pub async fn sandbox_delete(
35843584
server: &str,
35853585
names: &[String],
35863586
all: bool,
3587+
prune: bool,
35873588
workspace: &str,
35883589
tls: &TlsOptions,
35893590
gateway: &str,
35903591
) -> Result<()> {
35913592
let mut client = grpc_client(server, tls).await?;
35923593

3593-
let names_to_delete: Vec<String> = if all {
3594+
let names_to_delete: Vec<String> = if all || prune {
35943595
let mut page_token = String::new();
35953596
let mut sandboxes = Vec::new();
35963597
loop {
@@ -3604,7 +3605,16 @@ pub async fn sandbox_delete(
36043605
.await
36053606
.into_diagnostic()?
36063607
.into_inner();
3607-
sandboxes.extend(response.sandboxes);
3608+
3609+
if prune {
3610+
sandboxes.extend(response.sandboxes.into_iter().filter(|s| {
3611+
let phase = SandboxPhase::try_from(s.phase()).unwrap_or(SandboxPhase::Unknown);
3612+
[SandboxPhase::Error, SandboxPhase::Completed].contains(&phase)
3613+
}));
3614+
} else {
3615+
sandboxes.extend(response.sandboxes);
3616+
}
3617+
36083618
if response.next_page_token.is_empty() {
36093619
break;
36103620
}

‎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(
@@ -1577,6 +1581,24 @@ async fn add_provider(server: &TestServer, name: &str, provider_type: &str) {
15771581
});
15781582
}
15791583

1584+
async fn add_sandbox(server: &TestServer, name: &str, phase: SandboxPhase) {
1585+
let mut sandbox = Sandbox {
1586+
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
1587+
id: format!("sandbox-{name}"),
1588+
name: name.to_string(),
1589+
created_time: None,
1590+
labels: HashMap::new(),
1591+
resource_version: 0,
1592+
annotations: HashMap::new(),
1593+
workspace: String::new(),
1594+
deletion_time: None,
1595+
}),
1596+
..Sandbox::default()
1597+
};
1598+
sandbox.set_phase(phase as i32);
1599+
server.openshell.state.sandboxes.lock().await.push(sandbox);
1600+
}
1601+
15801602
fn test_tls(server: &TestServer) -> TlsOptions {
15811603
server.tls.with_gateway_name("openshell")
15821604
}
@@ -1613,6 +1635,7 @@ async fn sandbox_delete_continues_after_entry_failure() {
16131635
&server.endpoint,
16141636
&["failing-sandbox".to_string(), "later-sandbox".to_string()],
16151637
false,
1638+
false,
16161639
"default",
16171640
&tls,
16181641
"openshell",
@@ -1678,6 +1701,89 @@ async fn sandbox_create_tolerates_an_unreachable_profile_catalog() {
16781701
);
16791702
}
16801703

1704+
#[tokio::test]
1705+
async fn sandbox_delete_all() {
1706+
let server = run_server().await;
1707+
let tls = test_tls(&server);
1708+
1709+
add_sandbox(&server, "unspecified", SandboxPhase::Unspecified).await;
1710+
add_sandbox(&server, "provisioning", SandboxPhase::Provisioning).await;
1711+
add_sandbox(&server, "ready", SandboxPhase::Ready).await;
1712+
add_sandbox(&server, "error", SandboxPhase::Error).await;
1713+
add_sandbox(&server, "deleting", SandboxPhase::Deleting).await;
1714+
add_sandbox(&server, "stopping", SandboxPhase::Stopping).await;
1715+
add_sandbox(&server, "stopped", SandboxPhase::Stopped).await;
1716+
add_sandbox(&server, "starting", SandboxPhase::Starting).await;
1717+
add_sandbox(&server, "completed", SandboxPhase::Completed).await;
1718+
add_sandbox(&server, "unknown", SandboxPhase::Unknown).await;
1719+
1720+
assert!(
1721+
run::sandbox_delete(
1722+
&server.endpoint,
1723+
&[],
1724+
true,
1725+
false,
1726+
"default",
1727+
&tls,
1728+
"openshell"
1729+
)
1730+
.await
1731+
.is_ok()
1732+
);
1733+
1734+
assert_eq!(
1735+
deleted_names(&server).await,
1736+
vec![
1737+
vec!["unspecified".to_string()],
1738+
vec!["provisioning".to_string()],
1739+
vec!["ready".to_string()],
1740+
vec!["error".to_string()],
1741+
vec!["deleting".to_string()],
1742+
vec!["stopping".to_string()],
1743+
vec!["stopped".to_string()],
1744+
vec!["starting".to_string()],
1745+
vec!["completed".to_string()],
1746+
vec!["unknown".to_string()],
1747+
]
1748+
);
1749+
}
1750+
1751+
#[tokio::test]
1752+
async fn sandbox_delete_prune() {
1753+
let server = run_server().await;
1754+
let tls = test_tls(&server);
1755+
1756+
add_sandbox(&server, "unspecified", SandboxPhase::Unspecified).await;
1757+
add_sandbox(&server, "provisioning", SandboxPhase::Provisioning).await;
1758+
add_sandbox(&server, "ready", SandboxPhase::Ready).await;
1759+
add_sandbox(&server, "error", SandboxPhase::Error).await;
1760+
add_sandbox(&server, "deleting", SandboxPhase::Deleting).await;
1761+
add_sandbox(&server, "stopping", SandboxPhase::Stopping).await;
1762+
add_sandbox(&server, "stopped", SandboxPhase::Stopped).await;
1763+
add_sandbox(&server, "starting", SandboxPhase::Starting).await;
1764+
add_sandbox(&server, "completed", SandboxPhase::Completed).await;
1765+
add_sandbox(&server, "unknown", SandboxPhase::Unknown).await;
1766+
1767+
assert!(
1768+
run::sandbox_delete(
1769+
&server.endpoint,
1770+
&[],
1771+
false,
1772+
true,
1773+
"default",
1774+
&tls,
1775+
"openshell"
1776+
)
1777+
.await
1778+
.is_ok()
1779+
);
1780+
1781+
assert_eq!(
1782+
deleted_names(&server).await,
1783+
vec![vec!["error".to_string()], vec!["completed".to_string()],]
1784+
);
1785+
}
1786+
16811787
#[tokio::test]
16821788
async fn sandbox_create_keeps_command_sessions_by_default() {
16831789
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
@@ -461,6 +461,7 @@ openshell logs my-sandbox --since 5m
461461
openshell sandbox delete my-sandbox
462462
openshell sandbox delete sandbox-1 sandbox-2 sandbox-3 # Multiple at once
463463
openshell sandbox delete --all
464+
openshell sandbox delete --prune # Only inactive
464465
```
465466

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

0 commit comments

Comments
 (0)