Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0a26bcc
feat(gateway): add storage-preserving sandbox image updates
sjenning Oct 15, 2026
706fe35
feat(gateway): add storage-preserving sandbox image updates
sjenning Oct 15, 2026
b8829ee
fix(gateway): preserve image update patch-release compatibility
sjenning Oct 16, 2026
6408554
fix(gateway): preserve optional driver compatibility and repair CI
sjenning Oct 16, 2026
fc8b1f6
fix(gateway): preserve baseline factory and descriptor APIs
sjenning Oct 16, 2026
81c2da3
chore(ci): use upstream Kubernetes E2E defaults
sjenning Oct 16, 2026
5c2e96b
fix(gateway): integrate image updates with backend protocol
sjenning Oct 16, 2026
3ce3612
fix(gateway): remove sandbox image state on deletion
grs Oct 22, 2026
e7d4c5a
feat(gateway): expand UpdateSandbox configuration updates
grs Oct 23, 2026
8c5030d
fix(sandbox): recover pending updates and address review feedback
grs Oct 27, 2026
4030be5
perf(server): index pending sandbox updates for recovery
grs Oct 28, 2026
1ea7e92
docs(sandbox): clarify stop behavior when updates are disabled
grs Oct 28, 2026
c1abbb8
fix(server): scope deletion bypass to the matching sandbox update
grs Oct 28, 2026
d074b2d
fix(server): back off pending sandbox update recovery
grs Oct 28, 2026
a682fa1
ci: restore combined-chart Kubernetes e2e coverage
grs Oct 28, 2026
4bb5596
test(kubernetes): use uuid for update request IDs
grs Oct 28, 2026
3ff6359
fix(kubernetes): reject oversized annotations before accepting sandbo…
grs Oct 28, 2026
4cdde40
docs(skills): correct pending sandbox update recovery guidance
grs Oct 28, 2026
3333b5f
fix(sandbox): preserve terminal results when stopping missing compute
grs Oct 28, 2026
9aa04e1
fix(server): bound sandbox update attempts to unblock recovery and de…
grs Oct 28, 2026
41c4083
fix(server): remove unintended sandbox label count limit
grs Oct 29, 2026
f108248
fix(kubernetes): check known pod names during sandbox update preflight
grs Oct 29, 2026
84f97b1
docs: clarify sandbox update permissions and driver support
grs Oct 29, 2026
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
34 changes: 34 additions & 0 deletions .agents/skills/helm-dev-environment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,30 @@ above) instead of relying only on `OPENSHELL_GATEWAY_ENDPOINT`; some tests call
### Kubernetes E2E Notes

Use `mise run e2e:kubernetes` for the standard Helm-backed Kubernetes suite.
Set `OPENSHELL_E2E_KUBE_TEST=kubernetes_image_update` to scope the Rust suite to
combined image, command, environment, and metadata updates with retained sandbox,
SSH, and PVC identities. The next start verifies the new argv and environment. This target is
included in both existing Agent Sandbox API-version lanes; unlike the Python
Docker suite, it exercises the driver that supports image updates.

Image updates default to disabled outside test fixtures. The e2e wrapper opts
in only for a fresh gateway fixture that it owns; set
`OPENSHELL_E2E_KUBE_IMAGE_UPDATES=0` to prevent that opt-in. Do not propagate
activation to an existing shared gateway or enable it during a mixed-version
rollout. For manual testing, follow the retirement and activation checklist in
`docs/kubernetes/high-availability.mdx` before setting
`gatewayConfig: {"openshell.gateway": {enable_sandbox_updates: true}}`.
The gateway process equivalents are `--enable-sandbox-updates true` and
`OPENSHELL_ENABLE_SANDBOX_UPDATES=true`. Retire every old process and Job,
settle outstanding driver work, prevent old binaries returning, and do not
treat PostgreSQL connection markers or membership TTL expiry as retirement
proof. SQLite testing supports one gateway process only. Image-update clients
must call the separate `SandboxUpdates` service on the existing gateway
listener, not add a method to the baseline `OpenShell` service or inspect new
public `SandboxStatus` image fields. Resume a pending operation with
`SandboxUpdates.UpdateSandbox`, the current resource version, and no selected
fields, following the documented request-ID semantics.

The kube e2e wrapper creates only one port-forward, to `svc/openshell`; it no
longer forwards the unauthenticated health listener or runs a `/readyz` e2e
target. `/readyz` remains covered by server unit/integration tests.
Expand Down Expand Up @@ -611,3 +635,13 @@ for dependencies still declared in `Chart.yaml`.
| `deploy/kube/manifests/envoy-gateway-openshell.yaml` | GatewayClass and BackendTrafficPolicy for Envoy Gateway (`mise run helm:gateway:apply`) |
| `tasks/scripts/helm-k3s-local.sh` | k3d cluster create/delete/start/stop/status |
| `tasks/scripts/keycloak-k8s-setup.sh` | Keycloak deploy, realm import, and development TLS trust anchor |

## Sandbox update regression lane

Run `mise run e2e:kubernetes:updates` for the testkit update suite against both
supported Agent Sandbox APIs with the combined Helm chart and its bundled
workspace resources. It verifies stopped-only updates, retained CR/PVC
identity and contents, command/environment changes, and request replay.
`OPENSHELL_E2E_UPDATE_ORIGINAL_IMAGE` and `OPENSHELL_E2E_UPDATE_TARGET_IMAGE` can
select mirrored Alpine 3.21 and 3.22 manifests. Defaults use the public registry
cache; keep immutable digests when overriding them.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions crates/openshell-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4116,6 +4116,14 @@ pub async fn sandbox_stop(
.into_inner()
.sandbox
.ok_or_else(|| miette!("gateway returned no sandbox after stop"))?;
// A successful stop can preserve a canonical result when the transient
// compute resource is already gone. There is no Stopped event to wait for.
if sandbox.phase() == SandboxPhase::Completed as i32
|| (sandbox.phase() == SandboxPhase::Error as i32 && has_main_process_result(&sandbox))
{
println!("{} Sandbox {name} already finished", "✓".green().bold());
return Ok(());
}
wait_for_lifecycle_phase(&mut client, sandbox, SandboxPhase::Stopped).await?;
println!("{} Stopped sandbox {name}", "✓".green().bold());
Ok(())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ struct SandboxState {
create_requests: Arc<Mutex<Vec<CreateSandboxRequest>>>,
expose_service_requests: Arc<Mutex<Vec<openshell_core::proto::ExposeServiceRequest>>>,
fail_delete_sandbox_message: Arc<Mutex<Option<String>>>,
stop_response: Arc<Mutex<Option<Sandbox>>>,
vm_error_after_started: Arc<AtomicBool>,
vm_error_with_observed_exit: Arc<AtomicBool>,
vm_slow_progress_before_ready: Arc<AtomicBool>,
Expand Down Expand Up @@ -231,7 +232,17 @@ impl OpenShell for TestOpenShell {
&self,
_request: tonic::Request<openshell_core::proto::StopSandboxRequest>,
) -> Result<Response<SandboxResponse>, Status> {
Err(Status::unimplemented("unused"))
let sandbox = self
.state
.stop_response
.lock()
.await
.clone()
.ok_or_else(|| Status::unimplemented("unused"))?;
Ok(Response::new(SandboxResponse {
sandbox: Some(sandbox),
..Default::default()
}))
}

async fn start_sandbox(
Expand Down Expand Up @@ -1709,6 +1720,57 @@ fn test_config() -> run::SandboxCreateConfig<'static> {
}
}

#[tokio::test]
async fn sandbox_stop_accepts_terminal_results_without_waiting_for_stopped() {
let server = run_server().await;
let tls = test_tls(&server);
let config_dir = helpers::tempdir().unwrap();
for (phase, exit_code, reason, succeeds) in [
(
SandboxPhase::Completed,
Some(0),
"MainProcessCompleted",
true,
),
(SandboxPhase::Error, Some(17), "MainProcessFailed", true),
(SandboxPhase::Stopped, None, "Stopped", true),
(
SandboxPhase::Error,
Some(17),
"ComputeResourceMissing",
false,
),
] {
let sandbox = Sandbox {
status: Some(SandboxStatus {
phase: phase as i32,
exit_code,
conditions: vec![SandboxCondition {
r#type: "Ready".into(),
status: "False".into(),
reason: reason.into(),
..Default::default()
}],
..Default::default()
}),
..Default::default()
};
*server.openshell.state.stop_response.lock().await = Some(sandbox);
Environment::new()
.set("XDG_CONFIG_HOME", config_dir.path())
.run_async(async {
let result = tokio::time::timeout(
Duration::from_secs(5),
run::sandbox_stop(&server.endpoint, "terminal-stop-test", "default", &tls),
)
.await
.expect("stop must finish without watching for another phase");
assert_eq!(result.is_ok(), succeeds, "{reason}: {result:?}");
})
.await;
}
}

#[tokio::test]
async fn sandbox_delete_continues_after_entry_failure() {
let server = run_server().await;
Expand Down
14 changes: 14 additions & 0 deletions crates/openshell-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ configuration validation live in `openshell_core::middleware`. Policy admission
and the supervisor runtime consume the same contract without introducing a
dependency from the policy crate to the supervisor implementation.

## Optional Compute Updates

The generated `proto::compute::v1::compute_driver_server::ComputeDriver` trait
retains its baseline methods and streaming associated type. Supporting external
drivers additionally serve `ComputeDriverConfigurationUpdates` on the same
endpoint and advertise `openshell.compute.update-sandbox-configuration`.
The request carries image and lifecycle preconditions plus optional command and
environment replacements. Drivers without the capability need no new method.

Linked drivers opt in through `openshell_core::compute::ComputeDriver` and
`ComputeDriverFactory::build_with_configuration_updates`. The default factory
hook calls `build` without an updater. Registration and capability negotiation
are both required. Metadata-only updates are handled by the gateway.

## Protobuf Struct Conversion

Use `openshell_core::proto_struct` when crossing between `serde_json` values and
Expand Down
27 changes: 27 additions & 0 deletions crates/openshell-core/src/compute.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Opt-in in-process sandbox updates, separate from the baseline driver contract.

use crate::proto::compute::v1::compute_driver_server;

/// Optional in-process configuration updater, registered separately by a driver factory.
///
/// Existing drivers need only the generated baseline trait, not this extension.
/// Supporting factories opt in through `ComputeDriverFactory::build_with_configuration_updates`
/// and advertise the configuration-update capability. External servers use the
/// separate generated `ComputeDriverConfigurationUpdates` service on the same endpoint.
#[tonic::async_trait]
pub trait ComputeDriver: compute_driver_server::ComputeDriver {
async fn update_sandbox_configuration(
&self,
_request: tonic::Request<crate::proto::compute::v1::UpdateSandboxConfigurationRequest>,
) -> Result<
tonic::Response<crate::proto::compute::v1::UpdateSandboxConfigurationResponse>,
tonic::Status,
> {
Err(tonic::Status::unimplemented(
"sandbox configuration updates are not supported by this driver",
))
}
}
6 changes: 6 additions & 0 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ pub struct Config {
/// Disabled for local gateways by default.
pub enable_websocket_tunnel: bool,

/// Operator activation after all gateways and outstanding legacy driver
/// operations have been drained onto the configuration-update-compatible protocol.
/// Disabled by default; not a license to run mixed-version writers.
pub enable_sandbox_updates: bool,

/// Disabled-by-default gateway interceptor service configs.
pub gateway_interceptors: Vec<GatewayInterceptorConfig>,

Expand Down Expand Up @@ -999,6 +1004,7 @@ impl Config {
oidc: None,
auth: GatewayAuthConfig::default(),
enable_websocket_tunnel: false,
enable_sandbox_updates: false,
gateway_interceptors: Vec::new(),
provider_profile_sources: vec![GatewayProviderProfileSourceConfig::User],
mtls_auth: MtlsAuthConfig::default(),
Expand Down
22 changes: 22 additions & 0 deletions crates/openshell-core/src/extension_protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ pub const COMPUTE_ISOLATION_BACKEND: &str = "openshell.compute.isolation-backend
/// Driver implements `SuspendSandbox` and resuming through `StartSandbox`.
pub const COMPUTE_SUSPEND_RESUME: &str = "openshell.compute.suspend-resume";

/// Optional stopped-state workload configuration updates retaining the resource and storage.
pub const COMPUTE_UPDATE_SANDBOX_CONFIGURATION: &str =
"openshell.compute.update-sandbox-configuration";

const MAX_IMPLEMENTATION_NAME_BYTES: usize = 128;
const MAX_IMPLEMENTATION_VERSION_BYTES: usize = 128;
const MAX_CAPABILITY_BYTES: usize = 128;
Expand Down Expand Up @@ -128,6 +132,7 @@ pub fn gateway_metadata(family: ExtensionFamily) -> PeerMetadata {
if family == ExtensionFamily::Compute {
supported_capabilities.push(COMPUTE_LAUNCH_AUTHENTICATION.to_string());
supported_capabilities.push(COMPUTE_ISOLATION_BACKEND.to_string());
supported_capabilities.push(COMPUTE_UPDATE_SANDBOX_CONFIGURATION.to_string());
}
PeerMetadata {
protocol_version: Some(ProtocolVersion {
Expand Down Expand Up @@ -399,6 +404,23 @@ fn valid_capability(value: &str) -> bool {
mod tests {
use super::*;

#[test]
fn compute_gateway_advertises_both_optional_extensions() {
let gateway = gateway_metadata(ExtensionFamily::Compute);
assert!(
gateway
.supported_capabilities
.iter()
.any(|c| c == COMPUTE_ISOLATION_BACKEND)
);
assert!(
gateway
.supported_capabilities
.iter()
.any(|c| c == COMPUTE_UPDATE_SANDBOX_CONFIGURATION)
);
}

fn compatible() -> (PeerMetadata, PeerMetadata) {
let gateway = gateway_metadata(ExtensionFamily::Compute);
let extension = extension_metadata(
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

pub mod activity;
pub mod auth;
pub mod compute;
pub mod config;
pub mod container_paths;
pub mod denial;
Expand Down Expand Up @@ -58,6 +59,7 @@ pub mod rpc_error;
pub mod sandbox_env;
pub mod sandbox_generation;
pub mod sandbox_session;
pub mod sandbox_update;
pub mod secrets;
pub mod settings;
pub mod shell;
Expand Down
76 changes: 76 additions & 0 deletions crates/openshell-core/src/sandbox_update.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Shared projections for selected stopped-sandbox configuration changes.
use crate::proto::{Sandbox, SandboxCommandUpdate, SandboxMapUpdate};
use std::collections::HashMap;

/// Gateway-owned identity and lifecycle metadata is never caller-replaceable.
/// Other keys, including user keys under `OpenShell` domains, remain editable.
#[must_use]
pub fn is_system_metadata_key(key: &str) -> bool {
key.starts_with("internal.openshell.ai/")
|| matches!(
key,
"openshell.nvidia.com/retention"
| "openshell.ai/managed-by"
| "openshell.ai/sandbox-id"
| "openshell.ai/sandbox-name"
| "openshell.ai/sandbox-namespace"
| "openshell.ai/sandbox-workspace"
| "openshell.ai/gateway-id"
| "openshell.ai/caller-driver-config-used"
| "openshell.ai/resource-admission-identities"
)
}

fn replace_user_metadata(current: &mut HashMap<String, String>, update: &SandboxMapUpdate) {
current.retain(|key, _| is_system_metadata_key(key));
current.extend(update.values.clone());
}

/// Caller must validate syntax and forbid system keys before projecting.
pub fn apply_configuration(
sandbox: &mut Sandbox,
command: Option<&SandboxCommandUpdate>,
environment: Option<&SandboxMapUpdate>,
labels: Option<&SandboxMapUpdate>,
annotations: Option<&SandboxMapUpdate>,
) {
if let Some(spec) = sandbox.spec.as_mut() {
if let Some(command) = command {
spec.command.clone_from(&command.argv);
}
if let Some(environment) = environment {
spec.environment.clone_from(&environment.values);
}
}
if let Some(metadata) = sandbox.metadata.as_mut() {
if let Some(labels) = labels {
replace_user_metadata(&mut metadata.labels, labels);
}
if let Some(annotations) = annotations {
replace_user_metadata(&mut metadata.annotations, annotations);
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn replacement_preserves_owned_keys_without_freezing_user_domains() {
let mut metadata = HashMap::from([
("internal.openshell.ai/auth-epoch".into(), "trusted".into()),
("openshell.nvidia.com/retention".into(), "ephemeral".into()),
("openshell.ai/user-note".into(), "old".into()),
("agents.x-k8s.io/user-note".into(), "old".into()),
]);
replace_user_metadata(&mut metadata, &SandboxMapUpdate::default());
assert_eq!(metadata.len(), 2);
assert_eq!(metadata["openshell.nvidia.com/retention"], "ephemeral");
assert!(!is_system_metadata_key("openshell.ai/user-note"));
assert!(!is_system_metadata_key("agents.x-k8s.io/user-note"));
}
}
Loading
Loading