Skip to content

Commit 5c541e1

Browse files
authored
fix(kubernetes): prevent stop-start relay race (#3064)
Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com>
1 parent 22073fc commit 5c541e1

4 files changed

Lines changed: 81 additions & 14 deletions

File tree

crates/openshell-driver-kubernetes/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,9 @@ its pod. The driver sets `spec.operatingMode: Suspended` for `v1beta1` or
7979
`spec.replicas: 0` for `v1alpha1`. Start sets `Running` or one replica for the
8080
same resource, so the replacement pod mounts the existing claim. Delete is the
8181
only lifecycle operation that removes the Sandbox resource and its owned
82-
storage. The driver confirms the stop from the published `Suspended`
83-
condition when available. Legacy `v1alpha1` controllers omit a zero replica
84-
count from status, so the driver confirms that their backing pod is gone.
82+
storage. The driver confirms the stop from both the published `Suspended`
83+
condition and deletion of the backing pod. Legacy `v1alpha1` controllers omit
84+
a usable stopped condition, so pod deletion alone confirms their stop.
8585

8686
The workspace PVC size defaults to `workspace_default_storage_size`. Set
8787
`workspace_storage_class` to pin the PVC to a specific `StorageClass`; an empty

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

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1623,8 +1623,7 @@ impl KubernetesComputeDriver {
16231623
let (agent_sandbox_api, kube_name, pod_name, namespace, stop_timeout) = self
16241624
.patch_sandbox_operating_state(sandbox_id, false)
16251625
.await?;
1626-
let legacy_pod_api = (agent_sandbox_api.resource.version == SANDBOX_VERSION_V1ALPHA1)
1627-
.then(|| Api::<Pod>::namespaced(self.client.clone(), &namespace));
1626+
let pod_api = Api::<Pod>::namespaced(self.client.clone(), &namespace);
16281627

16291628
let deadline = tokio::time::Instant::now() + stop_timeout;
16301629
let mut poll_interval = STOP_INITIAL_POLL_INTERVAL;
@@ -1649,17 +1648,18 @@ impl KubernetesComputeDriver {
16491648
))
16501649
})?
16511650
.map_err(KubernetesDriverError::from_kube)?;
1652-
if kubernetes_sandbox_has_stopped_condition(&object) {
1653-
return Ok(());
1654-
}
16551651
if let Some(error) = kubernetes_sandbox_stop_failure(&object) {
16561652
return Err(KubernetesDriverError::Message(error));
16571653
}
1658-
if let Some(pod_api) = legacy_pod_api.as_ref()
1659-
&& kubernetes_sandbox_pod_is_gone(pod_api, &pod_name, deadline)
1660-
.await
1661-
.map_err(KubernetesDriverError::Message)?
1662-
{
1654+
let pod_is_gone = kubernetes_sandbox_pod_is_gone(&pod_api, &pod_name, deadline)
1655+
.await
1656+
.map_err(KubernetesDriverError::Message)?;
1657+
let stop_is_complete = kubernetes_sandbox_stop_is_complete(
1658+
&agent_sandbox_api.resource.version,
1659+
&object,
1660+
pod_is_gone,
1661+
);
1662+
if stop_is_complete {
16631663
return Ok(());
16641664
}
16651665
let now = tokio::time::Instant::now();
@@ -4560,6 +4560,19 @@ fn kubernetes_sandbox_has_stopped_condition(obj: &DynamicObject) -> bool {
45604560
})
45614561
}
45624562

4563+
fn kubernetes_sandbox_stop_is_complete(
4564+
api_version: &str,
4565+
obj: &DynamicObject,
4566+
pod_is_gone: bool,
4567+
) -> bool {
4568+
if api_version == SANDBOX_VERSION_V1ALPHA1 {
4569+
// v1alpha1 omits a usable stopped condition.
4570+
pod_is_gone
4571+
} else {
4572+
kubernetes_sandbox_has_stopped_condition(obj) && pod_is_gone
4573+
}
4574+
}
4575+
45634576
fn kubernetes_sandbox_stop_failure(obj: &DynamicObject) -> Option<String> {
45644577
obj.data
45654578
.get("status")?
@@ -5392,6 +5405,43 @@ mod tests {
53925405
assert!(kubernetes_sandbox_has_stopped_condition(&sandbox));
53935406
}
53945407

5408+
#[test]
5409+
fn beta_stop_requires_suspended_condition_and_deleted_pod() {
5410+
let resource = ApiResource::from_gvk(&GroupVersionKind::gvk(
5411+
SANDBOX_GROUP,
5412+
SANDBOX_VERSION_V1BETA1,
5413+
SANDBOX_KIND,
5414+
));
5415+
let mut sandbox = DynamicObject::new("sandbox", &resource);
5416+
5417+
assert!(!kubernetes_sandbox_stop_is_complete(
5418+
SANDBOX_VERSION_V1BETA1,
5419+
&sandbox,
5420+
true,
5421+
));
5422+
5423+
sandbox.data = serde_json::json!({
5424+
"status": {
5425+
"conditions": [{"type": "Suspended", "status": "True"}]
5426+
}
5427+
});
5428+
assert!(!kubernetes_sandbox_stop_is_complete(
5429+
SANDBOX_VERSION_V1BETA1,
5430+
&sandbox,
5431+
false,
5432+
));
5433+
assert!(kubernetes_sandbox_stop_is_complete(
5434+
SANDBOX_VERSION_V1BETA1,
5435+
&sandbox,
5436+
true,
5437+
));
5438+
assert!(kubernetes_sandbox_stop_is_complete(
5439+
SANDBOX_VERSION_V1ALPHA1,
5440+
&DynamicObject::new("sandbox", &resource),
5441+
true,
5442+
));
5443+
}
5444+
53955445
#[test]
53965446
fn stop_failure_only_rejects_terminal_suspension_condition() {
53975447
let resource = ApiResource::from_gvk(&GroupVersionKind::gvk(

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3183,6 +3183,17 @@ impl ComputeRuntime {
31833183
else {
31843184
return Ok(());
31853185
};
3186+
let phase = SandboxPhase::try_from(sandbox.phase()).unwrap_or(SandboxPhase::Unknown);
3187+
if matches!(
3188+
phase,
3189+
SandboxPhase::Deleting | SandboxPhase::Stopping | SandboxPhase::Stopped
3190+
) {
3191+
// Lifecycle shutdown intentionally discards the canonical process
3192+
// result. Finalization must still acknowledge that discarded
3193+
// result so the supervisor can exit before the compute backend's
3194+
// termination grace period expires.
3195+
return Ok(());
3196+
}
31863197
let Some(status) = sandbox.status.as_ref() else {
31873198
return Err("main-process exit has not been reported".to_string());
31883199
};
@@ -5600,6 +5611,10 @@ mod tests {
56005611
.main_process_exited(id, "instance-1", 143)
56015612
.await
56025613
.unwrap();
5614+
runtime
5615+
.finalize_main_process_exit(id, "instance-1")
5616+
.await
5617+
.expect("intentional shutdown finalization should be acknowledged");
56035618

56045619
let stored = runtime
56055620
.store

docs/reference/sandbox-compute-drivers.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,9 @@ The Kubernetes driver creates namespaced `agents.x-k8s.io` `Sandbox` resources f
436436
Stop patches the existing resource rather than deleting it. For `v1beta1`,
437437
the driver sets `spec.operatingMode` to `Suspended` or `Running`. For
438438
`v1alpha1`, it sets `spec.replicas` to `0` or `1`. The Sandbox resource and its
439-
workspace PVC keep their identity across both operations.
439+
workspace PVC keep their identity across both operations. Stop returns only
440+
after the controller reports suspension and deletes the old pod, so an
441+
immediate start cannot race the prior pod's termination.
440442

441443
If Agent Sandbox is upgraded in place, restart the OpenShell gateway after the controller and CRD rollout completes so the gateway can detect the served API versions again.
442444

0 commit comments

Comments
 (0)