Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/openshell-cli/src/commands/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ pub fn phase_name(phase: i32) -> &'static str {
Ok(SandboxPhase::Stopped) => "Stopped",
Ok(SandboxPhase::Starting) => "Starting",
Ok(SandboxPhase::Completed) => "Completed",
Ok(SandboxPhase::Suspending) => "Suspending",
Ok(SandboxPhase::Suspended) => "Suspended",
Ok(SandboxPhase::Unknown) | Err(_) => "Unknown",
}
}
Expand Down
59 changes: 59 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1714,6 +1714,25 @@ enum SandboxCommands {
name: Option<String>,
},

/// Suspend a ready sandbox and retain its memory state.
///
/// Requires a compute driver and isolation backend that support
/// suspend and resume. Use `sandbox resume` to restore the sandbox.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Suspend {
/// Sandbox name (defaults to last-used sandbox).
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: Option<String>,
},

/// Resume a suspended sandbox from its retained memory state.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Resume {
/// Sandbox name (defaults to last-used sandbox).
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: Option<String>,
},

/// Execute a command in a running sandbox.
///
/// Runs a command inside an existing sandbox using the gRPC exec endpoint.
Expand Down Expand Up @@ -3598,6 +3617,14 @@ async fn run_async() -> Result<()> {
let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?;
run::sandbox_start(endpoint, &name, &cli.workspace, &tls).await?;
}
SandboxCommands::Suspend { name } => {
let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?;
run::sandbox_suspend(endpoint, &name, &cli.workspace, &tls).await?;
}
SandboxCommands::Resume { name } => {
let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?;
run::sandbox_resume(endpoint, &name, &cli.workspace, &tls).await?;
}
SandboxCommands::Connect { name, editor } => {
let name = resolve_sandbox_name(name, &ctx.name, &cli.workspace)?;
if let Some(editor) = editor.map(Into::into) {
Expand Down Expand Up @@ -5542,6 +5569,38 @@ mod tests {
));
}

#[test]
fn sandbox_suspend_and_resume_accept_optional_names() {
let suspend = Cli::try_parse_from(["openshell", "sandbox", "suspend", "demo"])
.expect("suspend command should parse");
assert!(matches!(
suspend.command,
Some(Commands::Sandbox {
command: Some(SandboxCommands::Suspend { name: Some(ref name) }),
}) if name == "demo"
));

let resume = Cli::try_parse_from(["openshell", "sandbox", "resume"])
.expect("resume command should parse");
assert!(matches!(
resume.command,
Some(Commands::Sandbox {
command: Some(SandboxCommands::Resume { name: None }),
})
));

let named = Cli::try_parse_from(["openshell", "sandbox", "resume", "demo"])
.expect("named resume should parse");
assert!(matches!(
named.command,
Some(Commands::Sandbox {
command: Some(SandboxCommands::Resume {
name: Some(ref name),
}),
}) if name == "demo"
));
}

#[test]
fn sandbox_list_accepts_output_json() {
let cli = Cli::try_parse_from(["openshell", "sandbox", "list", "-o", "json"])
Expand Down
168 changes: 152 additions & 16 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,13 @@ use openshell_core::proto::{
GetSandboxTemplateRequest, GetServiceRequest, GpuResourceRequirements,
ListSandboxPoliciesRequest, ListSandboxTemplatesRequest, ListSandboxesRequest,
ListServicesRequest, PolicySource, PolicyStatus, RejectDraftChunkRequest, ResourceRequirements,
RevokeSshSessionRequest, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicy,
SandboxResources, SandboxRestartPolicy, SandboxServiceExposure, SandboxServiceLevel,
SandboxSpec, SandboxStartup, SandboxTemplate, SandboxWorkloadConfig, SandboxWorkloadTemplate,
SandboxWorkloadTemplateSpec, ServiceAuthorizationMode, ServiceEndpointResponse, SettingScope,
StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget,
UpdateConfigRequest, WatchSandboxRequest, exec_sandbox_event, tcp_forward_init,
ResumeSandboxRequest, RevokeSshSessionRequest, Sandbox, SandboxCondition, SandboxPhase,
SandboxPolicy, SandboxResources, SandboxRestartPolicy, SandboxServiceExposure,
SandboxServiceLevel, SandboxSpec, SandboxStartup, SandboxTemplate, SandboxWorkloadConfig,
SandboxWorkloadTemplate, SandboxWorkloadTemplateSpec, ServiceAuthorizationMode,
ServiceEndpointResponse, SettingScope, StartSandboxRequest, StopSandboxRequest,
SuspendSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest,
WatchSandboxRequest, exec_sandbox_event, tcp_forward_init,
};
use openshell_core::settings;
use openshell_core::{ObjectId, ObjectName, ObjectWorkspace};
Expand Down Expand Up @@ -2970,8 +2971,8 @@ pub async fn sandbox_list(
{
phase.red().to_string()
}
Ok(SandboxPhase::Provisioning) => phase.yellow().to_string(),
Ok(SandboxPhase::Deleting) => phase.dimmed().to_string(),
Ok(SandboxPhase::Provisioning | SandboxPhase::Suspending) => phase.yellow().to_string(),
Ok(SandboxPhase::Deleting | SandboxPhase::Suspended) => phase.dimmed().to_string(),
_ => phase.to_string(),
};
let created = format_epoch_ms(
Expand Down Expand Up @@ -4146,6 +4147,110 @@ pub async fn sandbox_start(
Ok(())
}

/// Suspend a ready sandbox, retaining its memory state, and wait until it is suspended.
pub async fn sandbox_suspend(
server: &str,
name: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let sandbox = client
.suspend_sandbox(SuspendSandboxRequest {
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
})
.await
.into_diagnostic()?
.into_inner()
.sandbox
.ok_or_else(|| miette!("gateway returned no sandbox after suspend"))?;
wait_for_lifecycle_phase(&mut client, sandbox, SandboxPhase::Suspended).await?;
if let Ok(stopped) = stop_forwards_for_sandbox(workspace, name) {
for port in stopped {
eprintln!(
"{} Stopped forward of port {port} for sandbox {name}",
"✓".green().bold(),
);
}
}

println!("{} Suspended sandbox {name}", "✓".green().bold());
Ok(())
}

/// Resume a suspended sandbox and wait until it is ready.
pub async fn sandbox_resume(
server: &str,
name: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
let mut client = grpc_client(server, tls).await?;
let sandbox = client
.resume_sandbox(ResumeSandboxRequest {
name: name.to_string(),
workspace_scope: Some(openshell_core::proto::workspace_selector(
workspace.to_string(),
)),
restart_if_restore_fails: false,
})
.await
.into_diagnostic()?
.into_inner()
.sandbox
.ok_or_else(|| miette!("gateway returned no sandbox after resume"))?;
wait_for_lifecycle_phase(&mut client, sandbox, SandboxPhase::Ready).await?;
println!("{} Resumed sandbox {name}", "✓".green().bold());
Ok(())
}

/// Whether `phase` means a lifecycle wait for `target` can no longer succeed
/// without another lifecycle request.
fn lifecycle_phase_blocks_target(target: SandboxPhase, phase: SandboxPhase) -> bool {
match target {
SandboxPhase::Ready => matches!(
phase,
SandboxPhase::Suspending | SandboxPhase::Suspended | SandboxPhase::Deleting
),
SandboxPhase::Suspended => matches!(
phase,
SandboxPhase::Ready
| SandboxPhase::Starting
| SandboxPhase::Stopping
| SandboxPhase::Stopped
| SandboxPhase::Deleting
),
SandboxPhase::Stopped => phase == SandboxPhase::Deleting,
_ => false,
}
}

fn lifecycle_phase_error(
sandbox: &Sandbox,
target: SandboxPhase,
phase: SandboxPhase,
) -> Option<miette::Report> {
if phase == SandboxPhase::Error {
let detail = ready_false_condition_message(sandbox.status.as_ref())
.unwrap_or_else(|| "sandbox entered Error".to_string());
return Some(miette!(
"{detail} while waiting for {}",
phase_name(target as i32)
));
}
if lifecycle_phase_blocks_target(target, phase) {
return Some(miette!(
"sandbox entered {} while waiting for {}",
phase_name(phase as i32),
phase_name(target as i32)
));
}
None
}

async fn wait_for_lifecycle_phase(
client: &mut crate::tls::GrpcClient,
sandbox: Sandbox,
Expand All @@ -4155,10 +4260,8 @@ async fn wait_for_lifecycle_phase(
if current == target {
return Ok(sandbox);
}
if current == SandboxPhase::Error {
let detail = ready_false_condition_message(sandbox.status.as_ref())
.unwrap_or_else(|| "sandbox entered Error".to_string());
return Err(miette!("{detail} while waiting for {target:?}"));
if let Some(err) = lifecycle_phase_error(&sandbox, target, current) {
return Err(err);
}

let timeout = Duration::from_secs(
Expand Down Expand Up @@ -4214,10 +4317,8 @@ async fn wait_for_lifecycle_phase(
if phase == target {
return Ok(sandbox);
}
if phase == SandboxPhase::Error {
let detail = ready_false_condition_message(sandbox.status.as_ref())
.unwrap_or_else(|| "sandbox entered Error".to_string());
return Err(miette!(detail));
if let Some(err) = lifecycle_phase_error(&sandbox, target, phase) {
return Err(err);
}
}
}
Expand Down Expand Up @@ -7026,6 +7127,41 @@ mod tests {
};
use openshell_core::proto::TcpForwardFrame;

#[test]
fn lifecycle_wait_fails_fast_on_phases_that_block_the_target() {
use super::lifecycle_phase_blocks_target;
use openshell_core::proto::SandboxPhase;

assert!(lifecycle_phase_blocks_target(
SandboxPhase::Ready,
SandboxPhase::Suspended
));
assert!(lifecycle_phase_blocks_target(
SandboxPhase::Ready,
SandboxPhase::Suspending
));
assert!(!lifecycle_phase_blocks_target(
SandboxPhase::Ready,
SandboxPhase::Starting
));
assert!(lifecycle_phase_blocks_target(
SandboxPhase::Suspended,
SandboxPhase::Stopped
));
assert!(lifecycle_phase_blocks_target(
SandboxPhase::Suspended,
SandboxPhase::Ready
));
assert!(!lifecycle_phase_blocks_target(
SandboxPhase::Suspended,
SandboxPhase::Suspending
));
assert!(!lifecycle_phase_blocks_target(
SandboxPhase::Stopped,
SandboxPhase::Suspended
));
}

#[test]
fn draft_approval_error_explains_refreshed_evaluation() {
use super::draft_approval_error;
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-cli/src/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,8 @@ async fn canonical_main_has_terminal_result(
| SandboxPhase::Error
| SandboxPhase::Stopping
| SandboxPhase::Stopped
| SandboxPhase::Suspending
| SandboxPhase::Suspended
| SandboxPhase::Deleting
)
{
Expand Down
14 changes: 14 additions & 0 deletions crates/openshell-cli/tests/ensure_providers_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,20 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("unused"))
}

async fn suspend_sandbox(
&self,
_request: tonic::Request<openshell_core::proto::SuspendSandboxRequest>,
) -> Result<Response<SandboxResponse>, Status> {
Err(Status::unimplemented("unused"))
}

async fn resume_sandbox(
&self,
_request: tonic::Request<openshell_core::proto::ResumeSandboxRequest>,
) -> Result<Response<SandboxResponse>, Status> {
Err(Status::unimplemented("unused"))
}

async fn get_sandbox(
&self,
_request: tonic::Request<GetSandboxRequest>,
Expand Down
14 changes: 14 additions & 0 deletions crates/openshell-cli/tests/mtls_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,20 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("unused"))
}

async fn suspend_sandbox(
&self,
_request: tonic::Request<openshell_core::proto::SuspendSandboxRequest>,
) -> Result<Response<openshell_core::proto::SandboxResponse>, Status> {
Err(Status::unimplemented("unused"))
}

async fn resume_sandbox(
&self,
_request: tonic::Request<openshell_core::proto::ResumeSandboxRequest>,
) -> Result<Response<openshell_core::proto::SandboxResponse>, Status> {
Err(Status::unimplemented("unused"))
}

async fn get_sandbox(
&self,
_request: tonic::Request<openshell_core::proto::GetSandboxRequest>,
Expand Down
14 changes: 14 additions & 0 deletions crates/openshell-cli/tests/provider_commands_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,20 @@ impl OpenShell for TestOpenShell {
Err(Status::unimplemented("unused"))
}

async fn suspend_sandbox(
&self,
_request: tonic::Request<openshell_core::proto::SuspendSandboxRequest>,
) -> Result<Response<SandboxResponse>, Status> {
Err(Status::unimplemented("unused"))
}

async fn resume_sandbox(
&self,
_request: tonic::Request<openshell_core::proto::ResumeSandboxRequest>,
) -> Result<Response<SandboxResponse>, Status> {
Err(Status::unimplemented("unused"))
}

async fn get_sandbox(
&self,
request: tonic::Request<GetSandboxRequest>,
Expand Down
Loading
Loading