Skip to content
Open
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
1 change: 1 addition & 0 deletions .agents/skills/debug-openshell-cluster/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ openshell logs <sandbox-name>
| Supervisor enters policy quarantine | A runtime candidate failed validation while `policy_validation_failure_mode = "fail_closed"` | Sandbox OCSF config/finding events, validation rationale, active generation, `previous_policy_active` |
| HTTP request returns `middleware_failed` or `middleware_denied` | Selected stage failed or explicitly denied the admitted request | Sandbox OCSF logs; policy-local middleware config; service availability; `on_error` |
| Custom compute driver is unavailable | Driver process/socket missing, inaccessible, or configured with a reserved/mismatched name | Socket ownership/mode, driver service logs, gateway `GetCapabilities` logs |
| Sandbox remains `Suspending` or `Resuming` | Driver stop/start failed, retained resource is missing, or the resumed supervisor has not reconnected | Gateway and driver logs; `docker inspect`, `podman inspect`, Agent Sandbox status/PVC, or VM state marker and launcher process |
| Image pull failure | Gateway or sandbox image cannot be pulled | Runtime events and image pull credentials |
| `K8s namespace not ready` with `envoy-gateway-openshell.yaml: the server could not find the requested resource` | Optional Gateway API manifest was applied without Envoy Gateway CRDs, or k3s Helm controller startup exceeded the namespace wait | Apply `deploy/kube/manifests/envoy-gateway-openshell.yaml` manually only after Envoy Gateway is installed and `grpcRoute` is enabled |
| HTTPS ingress (`grpcRoute.gateway.listener.protocol=HTTPS`) connection resets or TLS handshake hangs | Envoy terminates TLS but the gateway pod still expects TLS, so the plaintext backend hop fails | Set `server.disableTls=true` so Envoy forwards plaintext to the pod; verify the listener `certificateRefs` Secret exists in the release namespace and `openshell status` over `https://<host>` |
Expand Down
19 changes: 18 additions & 1 deletion .agents/skills/openshell-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,21 @@ openshell sandbox delete sandbox-1 sandbox-2 sandbox-3 # Multiple at once
openshell sandbox delete --all
```

### Suspend and resume sandboxes

Use suspension to stop compute while retaining the sandbox and its persistent
workspace:

```bash
openshell sandbox suspend [name]
openshell sandbox resume [name]
```

Both commands default to the last-used sandbox. Suspend stops background
forwards and waits for `Suspended`; resume waits for `Ready`. Connect, exec,
file transfer, forwarding, and exposed services are unavailable while
suspended. Delete remains the operation that removes retained state.

---

## Workflow 4: Policy Iteration Loop
Expand Down Expand Up @@ -636,7 +651,7 @@ The CLI help is always authoritative. If the help output contradicts this skill,

```bash
$ openshell sandbox --help
# Shows: create, get, list, delete, exec, connect, upload, download, ssh-config, provider
# Shows: create, get, list, suspend, resume, delete, exec, connect, upload, download, ssh-config, provider

$ openshell sandbox upload --help
# Shows: positional arguments (name, path, dest), usage examples
Expand All @@ -658,6 +673,8 @@ $ openshell sandbox upload --help
| Create sandbox with GPUs | `openshell sandbox create --gpu 1` |
| Create with custom policy | `openshell sandbox create --policy ./p.yaml` |
| Connect to sandbox | `openshell sandbox connect <name>` |
| Suspend sandbox compute | `openshell sandbox suspend [name]` |
| Resume sandbox compute | `openshell sandbox resume [name]` |
| Execute in sandbox | `openshell sandbox exec --name <name> -- <command>` |
| Stream live logs | `openshell logs <name> --tail` |
| Incremental policy update | `openshell policy update <name> --add-endpoint host:443:read-only:rest:enforce --binary /usr/bin/curl --wait` |
Expand Down
13 changes: 13 additions & 0 deletions .agents/skills/openshell-cli/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ openshell
│ ├── create [opts] [-- CMD...]
│ ├── get [name]
│ ├── list [opts]
│ ├── suspend [name]
│ ├── resume [name]
│ ├── delete [name]... [--all]
│ ├── exec [--name <name>] [opts] -- CMD...
│ ├── connect [name] [--editor <editor>]
Expand Down Expand Up @@ -250,6 +252,17 @@ Show sandbox details and the active policy. Metadata identifies sandbox or globa

Delete one or more named sandboxes, or use `--all`. Deletion stops background port forwards.

### `openshell sandbox suspend [name]`

Stop sandbox compute while retaining the sandbox and persistent workspace. The
name defaults to the last-used sandbox. The command stops background forwards
and waits for the `Suspended` phase.

### `openshell sandbox resume [name]`

Restart a suspended sandbox and wait for `Ready`. The name defaults to the
last-used sandbox.

### `openshell sandbox exec [OPTIONS] -- COMMAND...`

Execute a command through the gRPC exec endpoint, stream its output, and exit with the remote command's exit code.
Expand Down
33 changes: 28 additions & 5 deletions architecture/compute-runtimes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Compute Runtimes

Compute runtimes create, stop, delete, and watch sandbox workloads for the
Compute runtimes create, suspend, resume, delete, and watch sandbox workloads for the
gateway. They do not replace sandbox policy enforcement. Every runtime starts a
workload that runs the `openshell-sandbox` supervisor, and the supervisor
enforces the sandbox contract locally.
Expand Down Expand Up @@ -84,19 +84,42 @@ The gateway records driver identity and version from the startup capability
response. Elevated gateway info reports that initialized driver snapshot instead
of re-querying drivers on each request.

## Suspend and Resume Lifecycle

The gateway persists lifecycle intent before mutating compute:

```text
Ready -> Suspending -> Suspended -> Resuming -> Ready
```

`StopSandbox` and `ResumeSandbox` are idempotent driver operations. Suspension
retains the driver resource and its persistent workspace boundary while making
exec, SSH, forwarding, and exposed services unavailable. Resume reactivates the
same resource. The gateway requires a fresh supervisor session before a
resuming sandbox returns to `Ready`; stale driver snapshots and supervisor
sessions cannot promote a `Suspended` row.

Persisted `Suspending` and `Resuming` rows are retried at startup. Stable
`Suspended` rows remain stopped. Docker and Podman retain the stopped container
and attached storage, Kubernetes retains the Sandbox CR and PVC while scaling
compute to zero, and VM retains its launch request and writable overlay beside
a suspension marker. Delete remains a separate operation that removes these
resources.

## Deletion Lifecycle

Delete requests use per-sandbox gates to serialize delete attempts. A request
Lifecycle requests use per-sandbox gates to serialize suspend, resume, and
delete attempts. A delete request
resolves the name once and remains bound to that stable ID. The only
combined lock order is delete gate, then the gateway-wide state guard; external
combined lock order is lifecycle gate, then the gateway-wide state guard; external
driver calls run without the global guard.

Delete gates are process-local and do not coordinate gateway replicas. They
Lifecycle gates are process-local and do not coordinate gateway replicas. They
serialize attempts rather than share results: if one attempt fails and recovery
restores a deletable state, a request waiting on the gate may retry the driver.
Persisted resource-version checks remain the cross-replica safety boundary.

Watcher events do not acquire delete gates. Exact resource-version checks allow
Watcher events do not acquire lifecycle gates. Exact resource-version checks allow
them to interleave safely: status snapshots are no-ops for `Deleting` rows,
deleted events are idempotent, and snapshots for absent rows are ignored.

Expand Down
3 changes: 3 additions & 0 deletions crates/openshell-cli/src/commands/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ pub fn phase_name(phase: i32) -> &'static str {
Ok(SandboxPhase::Ready) => "Ready",
Ok(SandboxPhase::Error) => "Error",
Ok(SandboxPhase::Deleting) => "Deleting",
Ok(SandboxPhase::Suspending) => "Suspending",
Ok(SandboxPhase::Suspended) => "Suspended",
Ok(SandboxPhase::Resuming) => "Resuming",
Ok(SandboxPhase::Unknown) | Err(_) => "Unknown",
}
}
Expand Down
45 changes: 45 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1520,6 +1520,22 @@ enum SandboxCommands {
all: bool,
},

/// Suspend a sandbox while preserving its workspace.
#[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.
#[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 @@ -3132,6 +3148,14 @@ async fn main() -> Result<()> {
)
.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 @@ -4432,6 +4456,27 @@ 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 }),
})
));
}

#[test]
fn sandbox_list_accepts_output_json() {
let cli = Cli::try_parse_from(["openshell", "sandbox", "list", "-o", "json"])
Expand Down
133 changes: 131 additions & 2 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ use openshell_core::proto::{
ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest,
ListServicesRequest, PolicySource, PolicyStatus, Provider, ProviderCredentialRefreshStatus,
ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic,
ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements,
ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, ResumeSandboxRequest,
RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy,
SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetInferenceRouteRequest, SettingScope,
TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest,
SuspendSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest,
UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event,
setting_value, tcp_forward_init,
};
Expand Down Expand Up @@ -2419,6 +2419,135 @@ pub async fn sandbox_delete(
Ok(())
}

/// Suspend a sandbox while retaining its persistent workspace.
pub async fn sandbox_suspend(
server: &str,
name: &str,
workspace: &str,
tls: &TlsOptions,
) -> Result<()> {
if let Ok(stopped) = stop_forwards_for_sandbox(name) {
for port in stopped {
eprintln!(
"{} Stopped forward of port {port} for sandbox {name}",
"✓".green().bold(),
);
}
}

let mut client = grpc_client(server, tls).await?;
let sandbox = client
.suspend_sandbox(SuspendSandboxRequest {
name: name.to_string(),
workspace: 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?;
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: workspace.to_string(),
})
.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(())
}

async fn wait_for_lifecycle_phase(
client: &mut crate::tls::GrpcClient,
sandbox: Sandbox,
target: SandboxPhase,
) -> Result<Sandbox> {
let current = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown);
if current == target {
return Ok(sandbox);
}
if current == SandboxPhase::Error {
return Err(miette!(
"sandbox entered Error while waiting for {target:?}"
));
}

let timeout = Duration::from_secs(
std::env::var("OPENSHELL_LIFECYCLE_TIMEOUT")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(300),
);
let sandbox_id = sandbox.object_id().to_string();
let mut stream = client
.watch_sandbox(WatchSandboxRequest {
id: sandbox_id,
follow_status: true,
follow_logs: false,
follow_events: false,
log_tail_lines: 0,
event_tail: 0,
stop_on_terminal: false,
log_since_ms: 0,
log_sources: Vec::new(),
log_min_level: String::new(),
})
.await
.into_diagnostic()?
.into_inner();

let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(miette!(
"timed out after {}s waiting for sandbox to reach {target:?}",
timeout.as_secs()
));
}
let event = tokio::time::timeout(remaining, stream.next())
.await
.map_err(|_| {
miette!(
"timed out after {}s waiting for sandbox to reach {target:?}",
timeout.as_secs()
)
})?
.ok_or_else(|| miette!("sandbox watch ended before reaching {target:?}"))?
.into_diagnostic()?;
if let Some(openshell_core::proto::sandbox_stream_event::Payload::Sandbox(sandbox)) =
event.payload
{
let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown);
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));
}
}
}
}

/// Return the provider type inferred from the trailing command, if any.
fn inferred_provider_type(command: &[String]) -> Option<String> {
detect_provider_from_command(command).map(str::to_string)
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 @@ -111,6 +111,20 @@ impl OpenShell for TestOpenShell {
Ok(Response::new(SandboxResponse::default()))
}

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 @@ -66,6 +66,20 @@ impl OpenShell for TestOpenShell {
))
}

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
Loading
Loading