Skip to content

Commit 345ee3b

Browse files
committed
fix(bootstrap,server): persist sandbox state across gateway stop/start cycles
Sandbox pod data was lost whenever the gateway was stopped and restarted. Two independent bugs caused this: 1. k3s used the Docker container ID as its node name. When the container was recreated (e.g. after an image upgrade), a new node was registered and clean_stale_nodes() deleted all PVCs with node affinity for the old node — including the server's own SQLite database PVC. 2. Sandbox pods had no PersistentVolumeClaims, so their filesystem data lived in the ephemeral container layer and was lost on any pod reschedule. Fix (1) by passing a deterministic --node-name to k3s derived from the gateway name. The node identity now survives container recreation, so PVCs are never orphaned. Update clean_stale_nodes() to compare against the expected node name instead of running hostname inside the container. Fix (2) by injecting a default workspace volumeClaimTemplate into every sandbox pod spec (1Gi PVC mounted at /home/user). Users who supply their own volumeClaimTemplates are unaffected — the workspace mount is only added when the default VCT is used, preventing dangling volume references. Fixes #738
1 parent dd8dd8a commit 345ee3b

5 files changed

Lines changed: 211 additions & 19 deletions

File tree

‎crates/openshell-bootstrap/src/constants.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@ pub fn container_name(name: &str) -> String {
1818
format!("openshell-cluster-{name}")
1919
}
2020

21+
/// Deterministic k3s node name derived from the gateway name.
22+
///
23+
/// k3s defaults to using the container hostname (= Docker container ID) as
24+
/// the node name. When the container is recreated (e.g. after an image
25+
/// upgrade), the container ID changes, creating a new k3s node. The
26+
/// `clean_stale_nodes` function then deletes PVCs whose backing PVs have
27+
/// node affinity for the old node — wiping the server database and any
28+
/// sandbox persistent volumes.
29+
///
30+
/// By passing a deterministic `--node-name` to k3s, the node identity
31+
/// survives container recreation, and PVCs are never orphaned.
32+
pub fn node_name(name: &str) -> String {
33+
format!("openshell-{name}")
34+
}
35+
2136
pub fn volume_name(name: &str) -> String {
2237
format!("openshell-cluster-{name}")
2338
}

‎crates/openshell-bootstrap/src/docker.rs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
use crate::RemoteOptions;
5-
use crate::constants::{container_name, network_name, volume_name};
5+
use crate::constants::{container_name, network_name, node_name, volume_name};
66
use crate::image::{self, DEFAULT_IMAGE_REPO_BASE, DEFAULT_REGISTRY, parse_image_ref};
77
use bollard::API_DEFAULT_VERSION;
88
use bollard::Docker;
@@ -684,6 +684,11 @@ pub async fn ensure_container(
684684
format!("REGISTRY_HOST={registry_host}"),
685685
format!("REGISTRY_INSECURE={registry_insecure}"),
686686
format!("IMAGE_REPO_BASE={image_repo_base}"),
687+
// Deterministic k3s node name so the node identity survives container
688+
// recreation (e.g. after an image upgrade). Without this, k3s uses
689+
// the container ID as the hostname/node name, which changes on every
690+
// container recreate and triggers stale-node PVC cleanup.
691+
format!("OPENSHELL_NODE_NAME={}", node_name(name)),
687692
];
688693
if let Some(endpoint) = registry_endpoint {
689694
env_vars.push(format!("REGISTRY_ENDPOINT={endpoint}"));

‎crates/openshell-bootstrap/src/runtime.rs‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
use crate::constants::{KUBECONFIG_PATH, container_name};
4+
use crate::constants::{KUBECONFIG_PATH, container_name, node_name};
55
use bollard::Docker;
66
use bollard::container::LogOutput;
77
use bollard::exec::CreateExecOptions;
@@ -385,11 +385,17 @@ pub async fn clean_stale_nodes(docker: &Docker, name: &str) -> Result<usize> {
385385
let container_name = container_name(name);
386386
let mut stale_nodes: Vec<String> = Vec::new();
387387

388+
// Use the deterministic node name derived from the gateway name.
389+
// This matches the `--node-name` flag passed to k3s in the entrypoint
390+
// script. Any k3s node that doesn't match this name is stale (left over
391+
// from a previous container that used the container ID as hostname).
392+
let expected_node = node_name(name);
393+
388394
for attempt in 1..=MAX_ATTEMPTS {
389-
// List ALL node names and the container's own hostname. Any node that
390-
// is not the current container is stale — we cannot rely on the Ready
391-
// condition because k3s may not have marked the old node NotReady yet
392-
// when this runs shortly after container start.
395+
// List ALL node names. Any node that is not the expected deterministic
396+
// name is stale — we cannot rely on the Ready condition because k3s
397+
// may not have marked the old node NotReady yet when this runs shortly
398+
// after container start.
393399
let (output, exit_code) = exec_capture_with_exit(
394400
docker,
395401
&container_name,
@@ -406,16 +412,10 @@ pub async fn clean_stale_nodes(docker: &Docker, name: &str) -> Result<usize> {
406412
.await?;
407413

408414
if exit_code == 0 {
409-
// Determine the current node name (container hostname).
410-
let (hostname_out, _) =
411-
exec_capture_with_exit(docker, &container_name, vec!["hostname".to_string()])
412-
.await?;
413-
let current_hostname = hostname_out.trim().to_string();
414-
415415
stale_nodes = output
416416
.lines()
417417
.map(str::trim)
418-
.filter(|l| !l.is_empty() && *l != current_hostname)
418+
.filter(|l| !l.is_empty() && *l != expected_node)
419419
.map(ToString::to_string)
420420
.collect();
421421
break;

‎crates/openshell-server/src/sandbox/mod.rs‎

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ const SANDBOX_MANAGED_VALUE: &str = "openshell";
3434
const GPU_RESOURCE_NAME: &str = "nvidia.com/gpu";
3535
const GPU_RESOURCE_QUANTITY: &str = "1";
3636

37+
/// Name of the default PersistentVolumeClaim template added to sandbox pods
38+
/// so that workspace data survives pod rescheduling (e.g. across gateway
39+
/// stop/start cycles). The Sandbox CRD controller creates a PVC from this
40+
/// template and mounts it into the agent container.
41+
const DEFAULT_WORKSPACE_VOLUME_NAME: &str = "workspace";
42+
43+
/// Mount path for the default workspace PVC inside the agent container.
44+
const DEFAULT_WORKSPACE_MOUNT_PATH: &str = "/home/user";
45+
46+
/// Default storage size for the workspace PVC.
47+
const DEFAULT_WORKSPACE_STORAGE: &str = "1Gi";
48+
3749
#[derive(Clone)]
3850
pub struct SandboxClient {
3951
client: Client,
@@ -748,6 +760,18 @@ fn sandbox_to_k8s_spec(
748760
host_gateway_ip: &str,
749761
) -> serde_json::Value {
750762
let mut root = serde_json::Map::new();
763+
764+
// Determine whether the user provided custom volumeClaimTemplates.
765+
// When they haven't, we inject a default workspace VCT so sandbox data
766+
// persists across pod rescheduling. We must know this before building
767+
// the podTemplate so the workspace volume mount is only added when the
768+
// corresponding VCT is present — a dangling mount would prevent scheduling.
769+
let user_has_vct = spec
770+
.and_then(|s| s.template.as_ref())
771+
.and_then(|t| struct_to_json(&t.volume_claim_templates))
772+
.is_some();
773+
let inject_default_workspace = !user_has_vct;
774+
751775
if let Some(spec) = spec {
752776
if !spec.log_level.is_empty() {
753777
root.insert("logLevel".to_string(), serde_json::json!(spec.log_level));
@@ -775,6 +799,7 @@ fn sandbox_to_k8s_spec(
775799
&spec.environment,
776800
client_tls_secret_name,
777801
host_gateway_ip,
802+
inject_default_workspace,
778803
),
779804
);
780805
if !template.agent_socket.is_empty() {
@@ -789,6 +814,15 @@ fn sandbox_to_k8s_spec(
789814
}
790815
}
791816

817+
// Inject the default workspace volumeClaimTemplate when the user didn't
818+
// provide their own.
819+
if inject_default_workspace {
820+
root.insert(
821+
"volumeClaimTemplates".to_string(),
822+
default_workspace_volume_claim_templates(),
823+
);
824+
}
825+
792826
// podTemplate is required by the Kubernetes CRD - ensure it's always present
793827
if !root.contains_key("podTemplate") {
794828
let empty_env = std::collections::HashMap::new();
@@ -809,6 +843,7 @@ fn sandbox_to_k8s_spec(
809843
spec_env,
810844
client_tls_secret_name,
811845
host_gateway_ip,
846+
inject_default_workspace,
812847
),
813848
);
814849
}
@@ -833,6 +868,7 @@ fn sandbox_template_to_k8s(
833868
spec_environment: &std::collections::HashMap<String, String>,
834869
client_tls_secret_name: &str,
835870
host_gateway_ip: &str,
871+
inject_default_workspace: bool,
836872
) -> serde_json::Value {
837873
// The supervisor binary is always side-loaded from the node filesystem
838874
// via a hostPath volume, regardless of which sandbox image is used.
@@ -905,15 +941,34 @@ fn sandbox_template_to_k8s(
905941
}),
906942
);
907943

944+
// Build volume mounts for the agent container.
945+
let mut volume_mounts: Vec<serde_json::Value> = Vec::new();
946+
947+
// Default workspace volume mount so sandbox data persists across pod
948+
// rescheduling. Only added when the default workspace volumeClaimTemplate
949+
// was injected (i.e. the user didn't provide custom volumeClaimTemplates).
950+
// Adding this mount without the corresponding VCT would cause a dangling
951+
// reference that prevents the pod from scheduling.
952+
if inject_default_workspace {
953+
volume_mounts.push(serde_json::json!({
954+
"name": DEFAULT_WORKSPACE_VOLUME_NAME,
955+
"mountPath": DEFAULT_WORKSPACE_MOUNT_PATH
956+
}));
957+
}
958+
908959
// Mount client TLS secret for mTLS to the server.
909960
if !client_tls_secret_name.is_empty() {
961+
volume_mounts.push(serde_json::json!({
962+
"name": "openshell-client-tls",
963+
"mountPath": "/etc/openshell-tls/client",
964+
"readOnly": true
965+
}));
966+
}
967+
968+
if !volume_mounts.is_empty() {
910969
container.insert(
911970
"volumeMounts".to_string(),
912-
serde_json::json!([{
913-
"name": "openshell-client-tls",
914-
"mountPath": "/etc/openshell-tls/client",
915-
"readOnly": true
916-
}]),
971+
serde_json::Value::Array(volume_mounts),
917972
);
918973
}
919974

@@ -978,6 +1033,28 @@ fn container_resources(template: &SandboxTemplate, gpu: bool) -> Option<serde_js
9781033
}
9791034
}
9801035

1036+
/// Build the default `volumeClaimTemplates` array for sandbox pods.
1037+
///
1038+
/// Provides a single PVC named "workspace" that backs the sandbox home
1039+
/// directory. This ensures user data (files, installed packages, etc.)
1040+
/// persists across pod rescheduling — which happens whenever the gateway
1041+
/// is stopped and restarted.
1042+
fn default_workspace_volume_claim_templates() -> serde_json::Value {
1043+
serde_json::json!([{
1044+
"metadata": {
1045+
"name": DEFAULT_WORKSPACE_VOLUME_NAME
1046+
},
1047+
"spec": {
1048+
"accessModes": ["ReadWriteOnce"],
1049+
"resources": {
1050+
"requests": {
1051+
"storage": DEFAULT_WORKSPACE_STORAGE
1052+
}
1053+
}
1054+
}
1055+
}])
1056+
}
1057+
9811058
fn apply_gpu_limit(resources: &mut serde_json::Value) {
9821059
let Some(resources_obj) = resources.as_object_mut() else {
9831060
*resources = serde_json::json!({});
@@ -1631,6 +1708,7 @@ mod tests {
16311708
&std::collections::HashMap::new(),
16321709
"",
16331710
"",
1711+
true,
16341712
);
16351713

16361714
assert_eq!(
@@ -1664,6 +1742,7 @@ mod tests {
16641742
&std::collections::HashMap::new(),
16651743
"",
16661744
"",
1745+
true,
16671746
);
16681747

16691748
assert_eq!(
@@ -1693,6 +1772,7 @@ mod tests {
16931772
&std::collections::HashMap::new(),
16941773
"",
16951774
"",
1775+
true,
16961776
);
16971777

16981778
assert_eq!(
@@ -1735,6 +1815,7 @@ mod tests {
17351815
&std::collections::HashMap::new(),
17361816
"",
17371817
"",
1818+
true,
17381819
);
17391820

17401821
let limits = &pod_template["spec"]["containers"][0]["resources"]["limits"];
@@ -1761,6 +1842,7 @@ mod tests {
17611842
&std::collections::HashMap::new(),
17621843
"",
17631844
"172.17.0.1",
1845+
true,
17641846
);
17651847

17661848
let host_aliases = pod_template["spec"]["hostAliases"]
@@ -1791,6 +1873,7 @@ mod tests {
17911873
&std::collections::HashMap::new(),
17921874
"",
17931875
"",
1876+
true,
17941877
);
17951878

17961879
assert!(
@@ -1816,6 +1899,7 @@ mod tests {
18161899
&std::collections::HashMap::new(),
18171900
"my-tls-secret",
18181901
"",
1902+
true,
18191903
);
18201904

18211905
let volumes = pod_template["spec"]["volumes"]
@@ -1831,4 +1915,74 @@ mod tests {
18311915
"TLS secret volume must use mode 0400 to prevent sandbox user from reading the private key"
18321916
);
18331917
}
1918+
1919+
#[test]
1920+
fn default_workspace_mount_injected_when_no_custom_vct() {
1921+
let pod_template = sandbox_template_to_k8s(
1922+
&SandboxTemplate::default(),
1923+
false,
1924+
"openshell/sandbox:latest",
1925+
"",
1926+
"sandbox-id",
1927+
"sandbox-name",
1928+
"https://gateway.example.com",
1929+
"0.0.0.0:2222",
1930+
"secret",
1931+
300,
1932+
&std::collections::HashMap::new(),
1933+
"",
1934+
"",
1935+
true, // inject_default_workspace = true
1936+
);
1937+
1938+
let mounts = pod_template["spec"]["containers"][0]["volumeMounts"]
1939+
.as_array()
1940+
.expect("volumeMounts should exist");
1941+
let workspace_mount = mounts
1942+
.iter()
1943+
.find(|m| m["name"] == DEFAULT_WORKSPACE_VOLUME_NAME);
1944+
assert!(
1945+
workspace_mount.is_some(),
1946+
"workspace mount should be present when inject_default_workspace is true"
1947+
);
1948+
assert_eq!(
1949+
workspace_mount.unwrap()["mountPath"],
1950+
DEFAULT_WORKSPACE_MOUNT_PATH
1951+
);
1952+
}
1953+
1954+
#[test]
1955+
fn workspace_mount_not_injected_when_custom_vct_provided() {
1956+
// When the user provides custom volumeClaimTemplates, the workspace
1957+
// mount must NOT be added — a dangling mount would prevent scheduling.
1958+
let pod_template = sandbox_template_to_k8s(
1959+
&SandboxTemplate::default(),
1960+
false,
1961+
"openshell/sandbox:latest",
1962+
"",
1963+
"sandbox-id",
1964+
"sandbox-name",
1965+
"https://gateway.example.com",
1966+
"0.0.0.0:2222",
1967+
"secret",
1968+
300,
1969+
&std::collections::HashMap::new(),
1970+
"",
1971+
"",
1972+
false, // inject_default_workspace = false (user provided custom VCTs)
1973+
);
1974+
1975+
// volumeMounts should either be absent or not contain "workspace"
1976+
let has_workspace_mount = pod_template["spec"]["containers"][0]["volumeMounts"]
1977+
.as_array()
1978+
.map_or(false, |mounts| {
1979+
mounts
1980+
.iter()
1981+
.any(|m| m["name"] == DEFAULT_WORKSPACE_VOLUME_NAME)
1982+
});
1983+
assert!(
1984+
!has_workspace_mount,
1985+
"workspace mount must NOT be present when user provides custom volumeClaimTemplates"
1986+
);
1987+
}
18341988
}

‎deploy/docker/cluster-entrypoint.sh‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,8 +559,26 @@ fi
559559
# routing to settle first.
560560
wait_for_default_route
561561

562+
# ---------------------------------------------------------------------------
563+
# Deterministic k3s node name
564+
# ---------------------------------------------------------------------------
565+
# By default k3s uses the container hostname (= Docker container ID) as the
566+
# node name. When the container is recreated (e.g. after an image upgrade),
567+
# the container ID changes, registering a new k3s node. The bootstrap code
568+
# then deletes PVCs whose backing PVs have node affinity for the old node —
569+
# wiping the server database and any sandbox persistent volumes.
570+
#
571+
# OPENSHELL_NODE_NAME is set by the bootstrap code to a deterministic value
572+
# derived from the gateway name, so the node identity survives container
573+
# recreation and PVCs are never orphaned.
574+
NODE_NAME_ARG=""
575+
if [ -n "${OPENSHELL_NODE_NAME:-}" ]; then
576+
NODE_NAME_ARG="--node-name=${OPENSHELL_NODE_NAME}"
577+
echo "Using deterministic k3s node name: ${OPENSHELL_NODE_NAME}"
578+
fi
579+
562580
# Execute k3s with explicit resolv-conf passed as a kubelet arg.
563581
# k3s v1.35.2+ no longer accepts --resolv-conf as a top-level server flag;
564582
# it must be passed via --kubelet-arg instead.
565583
# shellcheck disable=SC2086
566-
exec /bin/k3s "$@" --kubelet-arg=resolv-conf="$RESOLV_CONF" $EXTRA_KUBELET_ARGS
584+
exec /bin/k3s "$@" $NODE_NAME_ARG --kubelet-arg=resolv-conf="$RESOLV_CONF" $EXTRA_KUBELET_ARGS

0 commit comments

Comments
 (0)