Skip to content

Commit 46b9a23

Browse files
committed
feat(compute): let drivers declare gateway lifecycle
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 8fef813 commit 46b9a23

10 files changed

Lines changed: 75 additions & 35 deletions

File tree

.agents/skills/debug-openshell-cluster/SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,9 @@ running intent should stop before the gateway exits and restart after it
199199
returns. Check for `Stopped sandbox during gateway shutdown` and `Started
200200
sandbox during gateway startup` in gateway logs. A sandbox explicitly stopped
201201
through the CLI remains stopped. Kubernetes sandboxes are cluster-owned and do
202-
not follow this local gateway lifecycle. An external driver configured under a
203-
different name is not included in the local gateway lifecycle sweep.
202+
not follow this local gateway lifecycle. Internal and external drivers follow
203+
the same rule: `GetCapabilities.gateway_managed_lifecycle` must be true for the
204+
gateway to run shutdown and startup sweeps.
204205

205206
### Step 5: Check Podman-Backed Gateways
206207

architecture/compute-runtimes.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ references to gateway-internal types. The gateway owns the public
2424
implementing `ComputeDriver` out of tree.
2525

2626
`compute_driver.proto` is the supported gateway/driver extension boundary.
27-
At initialization the gateway snapshots the driver's identity, version, and
28-
default image from `GetCapabilities`. Process-identity omissions are preserved
29-
across this boundary so every driver can apply its native image or runtime
30-
defaults. Driver-requested listeners are structurally validated and remain
31-
restricted to sandbox callback RPCs.
27+
At initialization the gateway snapshots the driver's identity, version,
28+
default image, and gateway-lifecycle preference from `GetCapabilities`.
29+
Process-identity omissions are preserved across this boundary so every driver
30+
can apply its native image or runtime defaults. Driver-requested listeners are
31+
structurally validated and remain restricted to sandbox callback RPCs.
3232

3333
Drivers own runtime-specific platform event interpretation. When an event should
3434
drive client provisioning UI, the driver attaches the shared
@@ -122,6 +122,11 @@ shared idempotent `StartSandbox` RPC before watch processing begins. Explicitly
122122
cluster-owned and continue running without gateway shutdown or startup
123123
lifecycle calls.
124124

125+
The driver reports this behavior through
126+
`GetCapabilities.gateway_managed_lifecycle`. The same declaration works for
127+
in-process and external drivers. Older drivers omit the field and retain the
128+
conservative operator-managed behavior.
129+
125130
## Deletion Lifecycle
126131

127132
Lifecycle requests use per-sandbox gates to serialize stop, start, and

crates/openshell-core/src/driver_utils.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,11 +380,13 @@ pub fn build_capabilities_response(
380380
driver_name: &str,
381381
driver_version: impl Into<String>,
382382
default_image: impl Into<String>,
383+
gateway_managed_lifecycle: bool,
383384
) -> GetCapabilitiesResponse {
384385
GetCapabilitiesResponse {
385386
driver_name: driver_name.to_string(),
386387
driver_version: driver_version.into(),
387388
default_image: default_image.into(),
389+
gateway_managed_lifecycle,
388390
}
389391
}
390392

crates/openshell-driver-docker/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,7 @@ impl DockerComputeDriver {
494494
"docker",
495495
&self.config.daemon_version,
496496
&self.config.default_image,
497+
true,
497498
)
498499
}
499500

crates/openshell-driver-kubernetes/src/driver.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,7 @@ impl KubernetesComputeDriver {
548548
"kubernetes",
549549
openshell_core::VERSION,
550550
&self.config.default_image,
551+
false,
551552
))
552553
}
553554

crates/openshell-driver-podman/src/driver.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,7 @@ impl PodmanComputeDriver {
468468
"podman",
469469
openshell_core::VERSION,
470470
&self.config.default_image,
471+
true,
471472
))
472473
}
473474

crates/openshell-driver-vm/src/driver.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,7 @@ impl VmDriver {
520520
driver_name: DRIVER_NAME.to_string(),
521521
driver_version: openshell_core::VERSION.to_string(),
522522
default_image: self.config.default_image.clone(),
523+
gateway_managed_lifecycle: true,
523524
}
524525
}
525526

crates/openshell-server/src/compute/mod.rs

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,8 @@ pub struct ComputeDriverInfoSnapshot {
274274
pub driver_name: String,
275275
/// Driver-reported implementation version from the startup capability snapshot.
276276
pub driver_version: String,
277+
/// Whether the driver asks the gateway to reconcile compute across restarts.
278+
pub gateway_managed_lifecycle: bool,
277279
}
278280

279281
/// Interval between store-vs-backend reconciliation sweeps.
@@ -609,6 +611,7 @@ impl ComputeRuntime {
609611
name: driver_name.clone(),
610612
driver_name: capabilities.driver_name,
611613
driver_version: capabilities.driver_version,
614+
gateway_managed_lifecycle: capabilities.gateway_managed_lifecycle,
612615
};
613616
let default_image = capabilities.default_image;
614617
let gateway_listener_requirements = match driver
@@ -2039,14 +2042,9 @@ impl ComputeRuntime {
20392042
/// persisted lifecycle intent.
20402043
///
20412044
/// An explicit sandbox stop persists `Stopped`; gateway shutdown does not.
2042-
/// Docker, Podman, and VM compute is stopped through the same public driver
2043-
/// RPC and restarted from retained running intent. Kubernetes remains
2044-
/// cluster-owned.
2045+
/// Drivers request this sweep through their startup capability snapshot.
20452046
async fn stop_persisted_sandboxes_on_shutdown(&self) -> Result<(), String> {
2046-
if !matches!(
2047-
self.driver_kind(),
2048-
Some(ComputeDriverKind::Docker | ComputeDriverKind::Podman | ComputeDriverKind::Vm)
2049-
) {
2047+
if !self.driver_info.gateway_managed_lifecycle {
20502048
return Ok(());
20512049
}
20522050

@@ -2129,17 +2127,15 @@ impl ComputeRuntime {
21292127
/// Reconcile running intent for local compute after a gateway restart.
21302128
///
21312129
/// `StartSandbox` is idempotent, so call it for every persisted phase that
2132-
/// requires running compute on Docker, Podman, and VM. Stable stopped,
2133-
/// deleting, and error states are deliberately left alone.
2130+
/// requires running compute for drivers that request gateway-managed
2131+
/// lifecycle. Stable stopped, deleting, and error states are deliberately
2132+
/// left alone.
21342133
///
21352134
/// Should be called once at gateway startup, before watchers spawn,
21362135
/// so the watch loop sees the post-start state on its first poll.
21372136
pub async fn start_persisted_sandboxes(&self) -> Result<(), String> {
21382137
self.recover_persisted_lifecycle_transitions().await?;
2139-
if !matches!(
2140-
self.driver_kind(),
2141-
Some(ComputeDriverKind::Docker | ComputeDriverKind::Podman | ComputeDriverKind::Vm)
2142-
) {
2138+
if !self.driver_info.gateway_managed_lifecycle {
21432139
return Ok(());
21442140
}
21452141

@@ -3910,6 +3906,7 @@ impl ComputeDriver for NoopTestDriver {
39103906
driver_name: "noop-test-driver".to_string(),
39113907
driver_version: "test".to_string(),
39123908
default_image: "openshell/sandbox:test".to_string(),
3909+
gateway_managed_lifecycle: false,
39133910
},
39143911
))
39153912
}
@@ -4050,6 +4047,7 @@ pub async fn new_test_runtime_with_driver(
40504047
name: driver_name.to_string(),
40514048
driver_name: driver_name.to_string(),
40524049
driver_version: "test".to_string(),
4050+
gateway_managed_lifecycle: false,
40534051
},
40544052
driver_process: None,
40554053
default_image: "openshell/sandbox:test".to_string(),
@@ -4212,6 +4210,7 @@ mod tests {
42124210
driver_name: "test-driver".to_string(),
42134211
driver_version: "test".to_string(),
42144212
default_image: "openshell/sandbox:test".to_string(),
4213+
gateway_managed_lifecycle: false,
42154214
}))
42164215
}
42174216

@@ -4529,6 +4528,7 @@ mod tests {
45294528
driver_name: "controlled-test-driver".to_string(),
45304529
driver_version: "test".to_string(),
45314530
default_image: "openshell/sandbox:test".to_string(),
4531+
gateway_managed_lifecycle: false,
45324532
}))
45334533
}
45344534

@@ -4731,6 +4731,7 @@ mod tests {
47314731
name: driver_name.to_string(),
47324732
driver_name: driver_name.to_string(),
47334733
driver_version: "test".to_string(),
4734+
gateway_managed_lifecycle: false,
47344735
},
47354736
driver_process: None,
47364737
default_image: "openshell/sandbox:test".to_string(),
@@ -4746,6 +4747,15 @@ mod tests {
47464747
}
47474748
}
47484749

4750+
async fn test_runtime_with_gateway_managed_lifecycle(
4751+
driver: SharedComputeDriver,
4752+
driver_name: &str,
4753+
) -> ComputeRuntime {
4754+
let mut runtime = test_runtime_for_driver(driver, driver_name).await;
4755+
runtime.driver_info.gateway_managed_lifecycle = true;
4756+
runtime
4757+
}
4758+
47494759
fn register_test_supervisor_session(runtime: &ComputeRuntime, sandbox_id: &str) {
47504760
let (tx, _rx) = mpsc::channel(1);
47514761
let (shutdown_tx, _shutdown_rx) = oneshot::channel();
@@ -8126,7 +8136,8 @@ mod tests {
81268136
#[tokio::test]
81278137
async fn shutdown_stops_running_intent_without_changing_persisted_phase() {
81288138
let driver = ControlledDriver::new();
8129-
let runtime = test_runtime_for_driver(driver.clone(), "docker").await;
8139+
let runtime =
8140+
test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await;
81308141

81318142
for (id, name, phase) in [
81328143
("sb-unspecified", "unspecified", SandboxPhase::Unspecified),
@@ -8185,7 +8196,8 @@ mod tests {
81858196
async fn shutdown_stop_sweep_continues_after_driver_errors() {
81868197
let driver = ControlledDriver::new();
81878198
driver.set_stop_outcome(ControlledLifecycleOutcome::Error("runtime angry"));
8188-
let runtime = test_runtime_for_driver(driver.clone(), "podman").await;
8199+
let runtime =
8200+
test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await;
81898201
for (id, name) in [("sb-1", "one"), ("sb-2", "two")] {
81908202
runtime
81918203
.store
@@ -8204,10 +8216,11 @@ mod tests {
82048216
}
82058217

82068218
#[tokio::test]
8207-
async fn shutdown_stop_sweep_runs_for_each_local_driver() {
8208-
for driver_name in ["docker", "podman", "vm"] {
8219+
async fn shutdown_stop_sweep_runs_for_any_capable_driver() {
8220+
for driver_name in ["arbitrary", "docker"] {
82098221
let driver = ControlledDriver::new();
8210-
let runtime = test_runtime_for_driver(driver.clone(), driver_name).await;
8222+
let runtime =
8223+
test_runtime_with_gateway_managed_lifecycle(driver.clone(), driver_name).await;
82118224
runtime
82128225
.store
82138226
.put_message(&sandbox_record("sb-1", "sandbox", SandboxPhase::Ready))
@@ -8228,8 +8241,8 @@ mod tests {
82288241
}
82298242

82308243
#[tokio::test]
8231-
async fn shutdown_stop_sweep_skips_kubernetes_and_extension_drivers() {
8232-
for driver_name in ["kubernetes", "extension"] {
8244+
async fn shutdown_stop_sweep_skips_drivers_without_capability() {
8245+
for driver_name in ["docker", "kubernetes", "extension"] {
82338246
let driver = ControlledDriver::new();
82348247
let runtime = test_runtime_for_driver(driver.clone(), driver_name).await;
82358248
runtime
@@ -8254,7 +8267,8 @@ mod tests {
82548267
#[tokio::test]
82558268
async fn start_persisted_sandboxes_starts_running_phases() {
82568269
let driver = ControlledDriver::new();
8257-
let runtime = test_runtime_for_driver(driver.clone(), "docker").await;
8270+
let runtime =
8271+
test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await;
82588272

82598273
for (id, name, phase) in [
82608274
("sb-unspecified", "unspecified", SandboxPhase::Unspecified),
@@ -8293,7 +8307,7 @@ mod tests {
82938307
async fn start_persisted_sandboxes_marks_missing_backend_as_error() {
82948308
let driver = ControlledDriver::new();
82958309
driver.set_start_outcome(ControlledLifecycleOutcome::NotFound);
8296-
let runtime = test_runtime_for_driver(driver, "podman").await;
8310+
let runtime = test_runtime_with_gateway_managed_lifecycle(driver, "arbitrary").await;
82978311

82988312
let sandbox = sandbox_record("sb-1", "missing", SandboxPhase::Ready);
82998313
runtime.store.put_message(&sandbox).await.unwrap();
@@ -8323,7 +8337,7 @@ mod tests {
83238337
async fn start_persisted_sandboxes_marks_failed_start_as_error() {
83248338
let driver = ControlledDriver::new();
83258339
driver.set_start_outcome(ControlledLifecycleOutcome::Error("runtime angry"));
8326-
let runtime = test_runtime_for_driver(driver, "vm").await;
8340+
let runtime = test_runtime_with_gateway_managed_lifecycle(driver, "arbitrary").await;
83278341

83288342
let sandbox = sandbox_record("sb-1", "broken", SandboxPhase::Provisioning);
83298343
runtime.store.put_message(&sandbox).await.unwrap();
@@ -8350,10 +8364,11 @@ mod tests {
83508364
}
83518365

83528366
#[tokio::test]
8353-
async fn start_persisted_sandboxes_runs_for_each_local_driver() {
8354-
for driver_name in ["docker", "podman", "vm"] {
8367+
async fn start_persisted_sandboxes_runs_for_any_capable_driver() {
8368+
for driver_name in ["arbitrary", "docker"] {
83558369
let driver = ControlledDriver::new();
8356-
let runtime = test_runtime_for_driver(driver.clone(), driver_name).await;
8370+
let runtime =
8371+
test_runtime_with_gateway_managed_lifecycle(driver.clone(), driver_name).await;
83578372
let sandbox = sandbox_record("sb-1", "local", SandboxPhase::Ready);
83588373
runtime.store.put_message(&sandbox).await.unwrap();
83598374

@@ -8368,8 +8383,8 @@ mod tests {
83688383
}
83698384

83708385
#[tokio::test]
8371-
async fn start_persisted_sandboxes_skips_kubernetes_and_extension_drivers() {
8372-
for driver_name in ["kubernetes", "extension"] {
8386+
async fn start_persisted_sandboxes_skips_drivers_without_capability() {
8387+
for driver_name in ["docker", "kubernetes", "extension"] {
83738388
let driver = ControlledDriver::new();
83748389
let runtime = test_runtime_for_driver(driver.clone(), driver_name).await;
83758390
let sandbox = sandbox_record("sb-1", "remote", SandboxPhase::Ready);
@@ -8596,6 +8611,7 @@ mod tests {
85968611
let driver = FakeComputeDriver::new()
85978612
.with_driver_name("fake-remote-driver")
85988613
.with_default_image("openshell/sandbox:remote")
8614+
.with_gateway_managed_lifecycle()
85998615
.with_gateway_listener_requirement(
86008616
"172.19.0.1:17670",
86018617
"external driver managed bridge",

crates/openshell-server/src/test_support.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ struct FakeComputeDriverState {
7474
driver_name: String,
7575
driver_version: String,
7676
default_image: String,
77+
gateway_managed_lifecycle: bool,
7778
gateway_listener_requirements: Vec<GatewayListenerRequirement>,
7879
gateway_listener_requirements_supported: bool,
7980
sandboxes: HashMap<String, DriverSandbox>,
@@ -95,6 +96,7 @@ impl FakeComputeDriver {
9596
driver_name: "fake-compute-driver".to_string(),
9697
driver_version: "test".to_string(),
9798
default_image: "openshell/sandbox:test".to_string(),
99+
gateway_managed_lifecycle: false,
98100
gateway_listener_requirements: Vec::new(),
99101
gateway_listener_requirements_supported: true,
100102
sandboxes: HashMap::new(),
@@ -122,6 +124,12 @@ impl FakeComputeDriver {
122124
self
123125
}
124126

127+
#[must_use]
128+
pub fn with_gateway_managed_lifecycle(self) -> Self {
129+
self.with_state(|state| state.gateway_managed_lifecycle = true);
130+
self
131+
}
132+
125133
#[must_use]
126134
pub fn with_gateway_listener_requirement(
127135
self,
@@ -241,6 +249,7 @@ impl ComputeDriver for FakeComputeDriver {
241249
driver_name: state.driver_name.clone(),
242250
driver_version: state.driver_version.clone(),
243251
default_image: state.default_image.clone(),
252+
gateway_managed_lifecycle: state.gateway_managed_lifecycle,
244253
}
245254
});
246255
Ok(Response::new(response))

proto/compute_driver.proto

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ message GetCapabilitiesResponse {
7575
string driver_version = 2;
7676
// Default sandbox image recommended by the driver.
7777
string default_image = 3;
78+
// Whether the gateway should stop running sandbox compute during graceful
79+
// shutdown and restart the retained running intent on startup.
80+
bool gateway_managed_lifecycle = 6;
7881
}
7982

8083
message GetGatewayListenerRequirementsRequest {}

0 commit comments

Comments
 (0)