@@ -34,6 +34,18 @@ const SANDBOX_MANAGED_VALUE: &str = "openshell";
3434const GPU_RESOURCE_NAME : & str = "nvidia.com/gpu" ;
3535const 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 ) ]
3850pub 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+
9811058fn 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}
0 commit comments