diff --git a/crates/openshell-core/src/driver_mounts.rs b/crates/openshell-core/src/driver_mounts.rs index 138fbdf1d..d0a2625ef 100644 --- a/crates/openshell-core/src/driver_mounts.rs +++ b/crates/openshell-core/src/driver_mounts.rs @@ -71,6 +71,19 @@ pub fn validate_container_mount_target(target: &str) -> Result<(), String> { if !target.starts_with('/') { return Err("mount target must be an absolute container path".to_string()); } + if target != "/" { + let segments = target.split('/').skip(1).collect::>(); + let has_internal_empty_segment = segments + .iter() + .take(segments.len().saturating_sub(1)) + .any(|segment| segment.is_empty()); + if has_internal_empty_segment || segments.iter().any(|segment| *segment == ".") { + return Err( + "mount target must be normalized and must not contain empty path segments or '.'" + .to_string(), + ); + } + } let path = Path::new(target); if path == Path::new("/") { return Err("mount target must not be the container root".to_string()); @@ -165,4 +178,17 @@ mod tests { "mount target must not contain surrounding whitespace" ); } + + #[test] + fn mount_target_rejects_internal_empty_or_dot_segments() { + assert_eq!( + validate_container_mount_target("/sandbox/work//tmp").unwrap_err(), + "mount target must be normalized and must not contain empty path segments or '.'" + ); + assert_eq!( + validate_container_mount_target("/sandbox/work/./tmp").unwrap_err(), + "mount target must be normalized and must not contain empty path segments or '.'" + ); + validate_container_mount_target("/sandbox/work/").unwrap(); + } } diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 923c6d618..35d2b974d 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -755,6 +755,39 @@ fn driver_config_allows_explicit_writable_volume_mounts() { assert_eq!(mounts[0].read_only, Some(false)); } +#[test] +fn driver_config_rejects_duplicate_mount_targets() { + let mut sandbox = test_sandbox(); + sandbox + .spec + .as_mut() + .unwrap() + .template + .as_mut() + .unwrap() + .driver_config = Some(json_struct(serde_json::json!({ + "mounts": [ + { + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work" + }, + { + "type": "tmpfs", + "target": "/sandbox/work" + } + ] + }))); + + let err = build_container_create_body(&sandbox, &runtime_config()).unwrap_err(); + + assert_eq!(err.code(), tonic::Code::FailedPrecondition); + assert!( + err.message() + .contains("duplicate docker driver_config mount target") + ); +} + #[test] fn driver_config_rejects_bind_mounts_unless_enabled() { let mut sandbox = test_sandbox(); diff --git a/crates/openshell-driver-kubernetes/README.md b/crates/openshell-driver-kubernetes/README.md index 831e4edf2..c2da97e49 100644 --- a/crates/openshell-driver-kubernetes/README.md +++ b/crates/openshell-driver-kubernetes/README.md @@ -82,6 +82,13 @@ nested schema and currently accepts: - `pod.priority_class_name` - `containers.agent.resources.requests` - `containers.agent.resources.limits` +- `containers.agent.volume_mounts[].name` +- `containers.agent.volume_mounts[].mount_path` +- `containers.agent.volume_mounts[].sub_path` +- `containers.agent.volume_mounts[].read_only` +- `volumes[].name` +- `volumes[].persistent_volume_claim.claim_name` +- `volumes[].persistent_volume_claim.read_only` Nested keys inside the `kubernetes` block use snake_case. The top-level `driver_config` envelope is keyed by driver names, so `kubernetes` is not part @@ -104,3 +111,50 @@ driver's configured `default_runtime_class_name`; the typed public public `--gpu` flag for the default GPU request, pass a count to `--gpu` for counted GPU requests, and use `driver_config` only for additional driver-owned resource details. + +Use PVC volumes to mount existing Kubernetes PersistentVolumeClaims into the +agent container. PVC volumes and mounts default to read-only unless +`read_only: false` is set explicitly. Read-write access requires +`read_only: false` on both the PVC volume and each writable mount. The driver +rejects duplicate volume names, invalid DNS-1123 volume labels or PVC claim +subdomain names, mounts that reference unknown volumes, non-normalized or +protected mount paths, and absolute or parent-traversing `sub_path` values. + +Any explicit driver-config mount under `/sandbox` disables the driver's +default `/sandbox` workspace PVC injection for that sandbox. Only the explicit +mount paths persist through the external PVC; other `/sandbox` paths come from +the current sandbox image. + +```shell +openshell sandbox create \ + --driver-config-json '{ + "kubernetes": { + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data-123", + "read_only": false + } + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/memory", + "sub_path": "memory", + "read_only": false + } + ] + } + } + } + }' \ + -- claude +``` diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index bdab5d120..77d91ab65 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -14,6 +14,7 @@ use kube::core::gvk::GroupVersionKind; use kube::core::{DynamicObject, ObjectMeta}; use kube::runtime::watcher::{self, Event}; use kube::{Client, Error as KubeError}; +use openshell_core::driver_mounts; use openshell_core::driver_utils::{ LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, SUPERVISOR_IMAGE_BINARY_PATH, }; @@ -32,7 +33,8 @@ use openshell_core::proto::compute::v1::{ }; use openshell_core::proto_struct::{struct_to_json_object, value_to_json}; use serde::Deserialize; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; +use std::path::Path; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; @@ -105,29 +107,38 @@ struct AgentSandboxApi { struct KubernetesSandboxDriverConfig { pod: KubernetesPodDriverConfig, containers: KubernetesDriverContainersConfig, + volumes: Vec, } impl KubernetesSandboxDriverConfig { - fn from_sandbox(sandbox: &Sandbox) -> Result { - let Some(template) = sandbox - .spec - .as_ref() - .and_then(|spec| spec.template.as_ref()) - else { - return Ok(Self::default()); - }; - - Self::from_template(template) - } - fn from_template(template: &SandboxTemplate) -> Result { let Some(config) = template.driver_config.as_ref() else { return Ok(Self::default()); }; let json = serde_json::Value::Object(struct_to_json_object(config)); - serde_json::from_value(json) - .map_err(|err| format!("invalid kubernetes driver_config: {err}")) + let config: Self = serde_json::from_value(json) + .map_err(|err| format!("invalid kubernetes driver_config: {err}"))?; + config + .validate() + .map_err(|err| format!("invalid kubernetes driver_config: {err}"))?; + Ok(config) + } + + fn validate(&self) -> Result<(), String> { + validate_kubernetes_driver_volumes(&self.volumes)?; + validate_kubernetes_driver_volume_mounts( + &self.volumes, + &self.containers.agent.volume_mounts, + ) + } + + fn has_explicit_sandbox_data_mount(&self) -> bool { + self.containers + .agent + .volume_mounts + .iter() + .any(|mount| path_is_or_under(&mount.mount_path, WORKSPACE_MOUNT_PATH)) } } @@ -150,6 +161,7 @@ struct KubernetesDriverContainersConfig { #[serde(default, deny_unknown_fields)] struct KubernetesContainerDriverConfig { resources: KubernetesContainerResourceConfig, + volume_mounts: Vec, } #[derive(Debug, Clone, Default, Deserialize)] @@ -159,6 +171,228 @@ struct KubernetesContainerResourceConfig { limits: BTreeMap, } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct KubernetesDriverVolumeConfig { + name: String, + persistent_volume_claim: KubernetesPersistentVolumeClaimConfig, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct KubernetesPersistentVolumeClaimConfig { + claim_name: String, + read_only: bool, +} + +impl Default for KubernetesPersistentVolumeClaimConfig { + fn default() -> Self { + Self { + claim_name: String::new(), + read_only: true, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct KubernetesDriverVolumeMountConfig { + name: String, + mount_path: String, + sub_path: Option, + read_only: bool, +} + +impl Default for KubernetesDriverVolumeMountConfig { + fn default() -> Self { + Self { + name: String::new(), + mount_path: String::new(), + sub_path: None, + read_only: true, + } + } +} + +const CLIENT_TLS_VOLUME_NAME: &str = "openshell-client-tls"; +const SERVICE_ACCOUNT_TOKEN_VOLUME_NAME: &str = "openshell-sa-token"; +const SERVICE_ACCOUNT_TOKEN_MOUNT_PATH: &str = "/var/run/secrets/openshell"; + +const KUBERNETES_DRIVER_RESERVED_VOLUME_NAMES: &[&str] = &[ + CLIENT_TLS_VOLUME_NAME, + SERVICE_ACCOUNT_TOKEN_VOLUME_NAME, + SPIFFE_WORKLOAD_API_VOLUME_NAME, + SUPERVISOR_VOLUME_NAME, + WORKSPACE_VOLUME_NAME, +]; + +const KUBERNETES_DRIVER_PROTECTED_MOUNT_PATHS: &[&str] = &[SERVICE_ACCOUNT_TOKEN_MOUNT_PATH]; + +fn validate_kubernetes_driver_volumes( + volumes: &[KubernetesDriverVolumeConfig], +) -> Result<(), String> { + let mut names = HashSet::new(); + for volume in volumes { + validate_kubernetes_dns1123_label(&volume.name, "volumes[].name")?; + let name = volume.name.as_str(); + if KUBERNETES_DRIVER_RESERVED_VOLUME_NAMES.contains(&name) { + return Err(format!( + "volume name '{name}' is reserved for OpenShell-managed volumes" + )); + } + if !names.insert(name) { + return Err(format!( + "duplicate kubernetes driver_config volume '{name}'" + )); + } + validate_kubernetes_dns1123_subdomain( + &volume.persistent_volume_claim.claim_name, + "volumes[].persistent_volume_claim.claim_name", + )?; + } + Ok(()) +} + +fn validate_kubernetes_driver_volume_mounts( + volumes: &[KubernetesDriverVolumeConfig], + volume_mounts: &[KubernetesDriverVolumeMountConfig], +) -> Result<(), String> { + let mut volume_read_only = BTreeMap::new(); + for volume in volumes { + volume_read_only.insert( + volume.name.as_str(), + volume.persistent_volume_claim.read_only, + ); + } + + let mut mount_paths = HashSet::new(); + for mount in volume_mounts { + validate_kubernetes_dns1123_label(&mount.name, "containers.agent.volume_mounts[].name")?; + let volume_name = mount.name.as_str(); + let Some(volume_is_read_only) = volume_read_only.get(volume_name) else { + return Err(format!( + "volume mount references unknown kubernetes driver_config volume '{volume_name}'" + )); + }; + if *volume_is_read_only && !mount.read_only { + return Err(format!( + "volume mount '{volume_name}' cannot set read_only=false because the PVC volume is read_only=true" + )); + } + + driver_mounts::validate_container_mount_target(&mount.mount_path)?; + let normalized_mount_path = driver_mounts::normalize_mount_target(&mount.mount_path); + if !mount_paths.insert(normalized_mount_path.clone()) { + return Err(format!( + "duplicate kubernetes driver_config mount target '{normalized_mount_path}'" + )); + } + + if let Some(sub_path) = mount.sub_path.as_ref() { + driver_mounts::validate_mount_subpath(sub_path)?; + } + } + Ok(()) +} + +fn validate_kubernetes_name_text(value: &str, field: &str) -> Result<(), String> { + if value != value.trim() { + return Err(format!( + "{field} must not contain leading or trailing whitespace" + )); + } + driver_mounts::validate_mount_source(value, field).map(|_| ()) +} + +fn is_kubernetes_dns1123_label(value: &str) -> bool { + !value.is_empty() + && value.len() <= 63 + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && value + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && value + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) +} + +fn validate_kubernetes_dns1123_label(value: &str, field: &str) -> Result<(), String> { + validate_kubernetes_name_text(value, field)?; + if !is_kubernetes_dns1123_label(value) { + return Err(format!( + "{field} must be a DNS-1123 label: use lowercase alphanumeric characters or '-', start and end with an alphanumeric character, and use at most 63 characters" + )); + } + Ok(()) +} + +fn validate_kubernetes_dns1123_subdomain(value: &str, field: &str) -> Result<(), String> { + validate_kubernetes_name_text(value, field)?; + let is_dns1123_subdomain = + value.len() <= 253 && value.split('.').all(is_kubernetes_dns1123_label); + if !is_dns1123_subdomain { + return Err(format!( + "{field} must be a DNS-1123 subdomain: use lowercase alphanumeric characters, '-' or '.', start and end with an alphanumeric character, and use at most 253 characters" + )); + } + Ok(()) +} + +fn mount_path_conflicts_with_protected_path(mount_path: &str, protected_path: &str) -> bool { + path_is_or_under(mount_path, protected_path) || path_is_or_under(protected_path, mount_path) +} + +fn path_is_or_under(path: &str, parent: &str) -> bool { + let path = Path::new(path); + let parent = Path::new(parent); + path == parent || path.starts_with(parent) +} + +fn validate_kubernetes_protected_path_conflicts( + volume_mounts: &[KubernetesDriverVolumeMountConfig], + protected_paths: &[&str], +) -> Result<(), String> { + for mount in volume_mounts { + let mount_path = mount.mount_path.as_str(); + for protected_path in protected_paths { + if mount_path_conflicts_with_protected_path(mount_path, protected_path) { + return Err(format!( + "mount path '{mount_path}' conflicts with reserved OpenShell path '{protected_path}'" + )); + } + } + } + Ok(()) +} + +fn kubernetes_driver_volume_to_k8s(volume: &KubernetesDriverVolumeConfig) -> serde_json::Value { + serde_json::json!({ + "name": volume.name.as_str(), + "persistentVolumeClaim": { + "claimName": volume.persistent_volume_claim.claim_name.as_str(), + "readOnly": volume.persistent_volume_claim.read_only, + } + }) +} + +fn kubernetes_driver_volume_mount_to_k8s( + mount: &KubernetesDriverVolumeMountConfig, +) -> serde_json::Value { + let mut value = serde_json::json!({ + "name": mount.name.as_str(), + "mountPath": mount.mount_path.as_str(), + "readOnly": mount.read_only, + }); + if let Some(sub_path) = mount.sub_path.as_ref() { + value["subPath"] = serde_json::json!(sub_path); + } + value +} + // --------------------------------------------------------------------------- // Default workspace persistence (temporary — will be replaced by snapshotting) // --------------------------------------------------------------------------- @@ -266,6 +500,20 @@ impl KubernetesComputeDriver { &self.config.ssh_socket_path } + fn validate_driver_config_for_sandbox( + &self, + sandbox: &Sandbox, + ) -> Result { + kubernetes_driver_config_for_spec( + sandbox.spec.as_ref(), + self.config.provider_spiffe_enabled().then_some( + self.config + .provider_spiffe_workload_api_socket_path + .as_str(), + ), + ) + } + fn agent_sandbox_api(&self, client: Client, sandbox_api_version: &str) -> AgentSandboxApi { let gvk = GroupVersionKind::gvk(SANDBOX_GROUP, sandbox_api_version, SANDBOX_KIND); let resource = ApiResource::from_gvk(&gvk); @@ -342,7 +590,8 @@ impl KubernetesComputeDriver { } pub async fn validate_sandbox_create(&self, sandbox: &Sandbox) -> Result<(), tonic::Status> { - let _ = KubernetesSandboxDriverConfig::from_sandbox(sandbox) + let _ = self + .validate_driver_config_for_sandbox(sandbox) .map_err(tonic::Status::invalid_argument)?; let gpu_requirements = sandbox .spec @@ -450,8 +699,6 @@ impl KubernetesComputeDriver { } pub async fn create_sandbox(&self, sandbox: &Sandbox) -> Result<(), KubernetesDriverError> { - let _ = KubernetesSandboxDriverConfig::from_sandbox(sandbox) - .map_err(KubernetesDriverError::InvalidArgument)?; let gpu_requirements = sandbox .spec .as_ref() @@ -467,17 +714,6 @@ impl KubernetesComputeDriver { "Creating sandbox in Kubernetes" ); - let agent_sandbox_api = self - .supported_agent_sandbox_api(self.client.clone()) - .await - .map_err(KubernetesDriverError::Message)?; - let mut obj = DynamicObject::new(name, &agent_sandbox_api.resource); - obj.metadata = ObjectMeta { - name: Some(name.to_string()), - namespace: Some(self.config.namespace.clone()), - labels: Some(sandbox_labels(sandbox)), - ..Default::default() - }; let params = SandboxPodParams { default_image: &self.config.default_image, image_pull_policy: &self.config.image_pull_policy, @@ -502,7 +738,20 @@ impl KubernetesComputeDriver { .config .provider_spiffe_workload_api_socket_path, }; - obj.data = sandbox_to_k8s_spec(sandbox.spec.as_ref(), ¶ms); + let data = sandbox_to_k8s_spec(sandbox.spec.as_ref(), ¶ms) + .map_err(KubernetesDriverError::InvalidArgument)?; + let agent_sandbox_api = self + .supported_agent_sandbox_api(self.client.clone()) + .await + .map_err(KubernetesDriverError::Message)?; + let mut obj = DynamicObject::new(name, &agent_sandbox_api.resource); + obj.metadata = ObjectMeta { + name: Some(name.to_string()), + namespace: Some(self.config.namespace.clone()), + labels: Some(sandbox_labels(sandbox)), + ..Default::default() + }; + obj.data = data; match tokio::time::timeout( KUBE_API_TIMEOUT, agent_sandbox_api.api.create(&PostParams::default(), &obj), @@ -1287,27 +1536,55 @@ fn spec_pod_env(spec: Option<&SandboxSpec>) -> std::collections::HashMap KubernetesSandboxDriverConfig { - KubernetesSandboxDriverConfig::from_template(template) - .expect("validated Kubernetes driver_config") +fn kubernetes_driver_config_for_spec( + spec: Option<&SandboxSpec>, + provider_spiffe_workload_api_socket_path: Option<&str>, +) -> Result { + let config = spec + .and_then(|spec| spec.template.as_ref()) + .map(KubernetesSandboxDriverConfig::from_template) + .transpose()? + .unwrap_or_default(); + let mut protected_paths = KUBERNETES_DRIVER_PROTECTED_MOUNT_PATHS.to_vec(); + let provider_spiffe_mount_path; + if let Some(socket_path) = provider_spiffe_workload_api_socket_path { + provider_spiffe_mount_path = spiffe_socket_mount_path(socket_path); + protected_paths.push(&provider_spiffe_mount_path); + } + validate_kubernetes_protected_path_conflicts( + &config.containers.agent.volume_mounts, + &protected_paths, + )?; + Ok(config) } fn sandbox_to_k8s_spec( spec: Option<&SandboxSpec>, params: &SandboxPodParams<'_>, -) -> serde_json::Value { +) -> Result { + let driver_config = + kubernetes_driver_config_for_spec(spec, provider_spiffe_socket_path(params))?; let mut root = serde_json::Map::new(); + // Determine early whether OpenShell should inject its default workspace + // PVC. Explicit Kubernetes driver-config mounts under /sandbox/ take + // ownership of workspace persistence. + // We need this flag before building the podTemplate because the workspace + // persistence transforms are applied inside sandbox_template_to_k8s. + let user_has_explicit_workspace_mount = driver_config.has_explicit_sandbox_data_mount(); + let inject_workspace = !user_has_explicit_workspace_mount; + if let Some(spec) = spec { let pod_env = spec_pod_env(Some(spec)); if let Some(template) = spec.template.as_ref() { root.insert( "podTemplate".to_string(), - sandbox_template_to_k8s_with_gpu_requirements( + sandbox_template_to_k8s_with_validated_config( template, driver_gpu_requirements(spec.resource_requirements.as_ref()), &pod_env, - true, + &driver_config, + inject_workspace, params, ), ); @@ -1320,29 +1597,32 @@ fn sandbox_to_k8s_spec( } } - root.insert( - "volumeClaimTemplates".to_string(), - default_workspace_volume_claim_templates(params.workspace_default_storage_size), - ); + if inject_workspace { + root.insert( + "volumeClaimTemplates".to_string(), + default_workspace_volume_claim_templates(params.workspace_default_storage_size), + ); + } // podTemplate is required by the Kubernetes CRD - ensure it's always present if !root.contains_key("podTemplate") { let pod_env = spec_pod_env(spec); root.insert( "podTemplate".to_string(), - sandbox_template_to_k8s_with_gpu_requirements( + sandbox_template_to_k8s_with_validated_config( &SandboxTemplate::default(), driver_gpu_requirements(spec.and_then(|s| s.resource_requirements.as_ref())), &pod_env, - true, + &driver_config, + inject_workspace, params, ), ); } - serde_json::Value::Object( + Ok(serde_json::Value::Object( std::iter::once(("spec".to_string(), serde_json::Value::Object(root))).collect(), - ) + )) } #[cfg(test)] @@ -1354,15 +1634,19 @@ fn sandbox_template_to_k8s( params: &SandboxPodParams<'_>, ) -> serde_json::Value { let gpu_requirements = gpu.then_some(GpuResourceRequirements { count: None }); - sandbox_template_to_k8s_with_gpu_requirements( + let driver_config = KubernetesSandboxDriverConfig::from_template(template) + .expect("test Kubernetes driver_config should be valid"); + sandbox_template_to_k8s_with_validated_config( template, gpu_requirements.as_ref(), spec_environment, + &driver_config, inject_workspace, params, ) } +#[cfg(test)] fn sandbox_template_to_k8s_with_gpu_requirements( template: &SandboxTemplate, gpu_requirements: Option<&GpuResourceRequirements>, @@ -1370,8 +1654,26 @@ fn sandbox_template_to_k8s_with_gpu_requirements( inject_workspace: bool, params: &SandboxPodParams<'_>, ) -> serde_json::Value { - let driver_config = kubernetes_driver_config(template); + let driver_config = KubernetesSandboxDriverConfig::from_template(template) + .expect("test Kubernetes driver_config should be valid"); + sandbox_template_to_k8s_with_validated_config( + template, + gpu_requirements, + spec_environment, + &driver_config, + inject_workspace, + params, + ) +} +fn sandbox_template_to_k8s_with_validated_config( + template: &SandboxTemplate, + gpu_requirements: Option<&GpuResourceRequirements>, + spec_environment: &std::collections::HashMap, + driver_config: &KubernetesSandboxDriverConfig, + inject_workspace: bool, + params: &SandboxPodParams<'_>, +) -> serde_json::Value { let mut metadata = serde_json::Map::new(); let mut pod_labels = template .labels @@ -1535,7 +1837,7 @@ fn sandbox_template_to_k8s_with_gpu_requirements( let mut volume_mounts: Vec = Vec::new(); if !params.client_tls_secret_name.is_empty() { volume_mounts.push(serde_json::json!({ - "name": "openshell-client-tls", + "name": CLIENT_TLS_VOLUME_NAME, "mountPath": "/etc/openshell-tls/client", "readOnly": true })); @@ -1548,10 +1850,18 @@ fn sandbox_template_to_k8s_with_gpu_requirements( })); } volume_mounts.push(serde_json::json!({ - "name": "openshell-sa-token", - "mountPath": "/var/run/secrets/openshell", + "name": SERVICE_ACCOUNT_TOKEN_VOLUME_NAME, + "mountPath": SERVICE_ACCOUNT_TOKEN_MOUNT_PATH, "readOnly": true, })); + volume_mounts.extend( + driver_config + .containers + .agent + .volume_mounts + .iter() + .map(kubernetes_driver_volume_mount_to_k8s), + ); container.insert( "volumeMounts".to_string(), serde_json::Value::Array(volume_mounts), @@ -1571,7 +1881,7 @@ fn sandbox_template_to_k8s_with_gpu_requirements( let mut volumes: Vec = Vec::new(); if !params.client_tls_secret_name.is_empty() { volumes.push(serde_json::json!({ - "name": "openshell-client-tls", + "name": CLIENT_TLS_VOLUME_NAME, "secret": { "secretName": params.client_tls_secret_name, "defaultMode": 256 } })); } @@ -1589,7 +1899,7 @@ fn sandbox_template_to_k8s_with_gpu_requirements( // it automatically. The supervisor exchanges this for a gateway-minted // JWT via `IssueSandboxToken` once at startup. volumes.push(serde_json::json!({ - "name": "openshell-sa-token", + "name": SERVICE_ACCOUNT_TOKEN_VOLUME_NAME, "projected": { "sources": [{ "serviceAccountToken": { @@ -1601,6 +1911,12 @@ fn sandbox_template_to_k8s_with_gpu_requirements( "defaultMode": 256 } })); + volumes.extend( + driver_config + .volumes + .iter() + .map(kubernetes_driver_volume_to_k8s), + ); spec.insert("volumes".to_string(), serde_json::Value::Array(volumes)); // Add hostAliases so sandbox pods can reach the Docker host. @@ -1630,8 +1946,8 @@ fn sandbox_template_to_k8s_with_gpu_requirements( ); // Inject workspace persistence (init container + PVC volume mount) so - // that /sandbox data survives pod rescheduling. Skipped when the user - // provides custom volumeClaimTemplates to avoid conflicts. + // that /sandbox data survives pod rescheduling. Skipped when the user + // provides custom storage through driver_config. if inject_workspace { apply_workspace_persistence(&mut result, image, params.image_pull_policy); } @@ -1920,9 +2236,9 @@ fn provider_spiffe_socket_path<'a>(params: &'a SandboxPodParams<'a>) -> Option<& } fn spiffe_socket_mount_path(socket_path: &str) -> String { - std::path::Path::new(socket_path) + Path::new(socket_path) .parent() - .and_then(std::path::Path::to_str) + .and_then(Path::to_str) .filter(|path| !path.is_empty() && *path != "/") .expect("provider SPIFFE socket path should be validated before pod rendering") .to_string() @@ -2086,6 +2402,13 @@ mod tests { } } + fn sandbox_to_k8s_spec_for_test( + spec: Option<&SandboxSpec>, + params: &SandboxPodParams<'_>, + ) -> serde_json::Value { + sandbox_to_k8s_spec(spec, params).expect("test Kubernetes driver_config should be valid") + } + fn kube_api_error(code: u16, message: &str) -> KubeError { KubeError::Api(kube::core::ErrorResponse { status: if code == 404 { @@ -2143,7 +2466,7 @@ mod tests { } #[test] - fn driver_config_from_sandbox_rejects_unknown_fields() { + fn driver_config_for_spec_rejects_unknown_fields() { let sandbox = Sandbox { id: "sandbox-123".to_string(), spec: Some(SandboxSpec { @@ -2158,11 +2481,579 @@ mod tests { ..Default::default() }; - let err = KubernetesSandboxDriverConfig::from_sandbox(&sandbox).unwrap_err(); + let err = kubernetes_driver_config_for_spec(sandbox.spec.as_ref(), None).unwrap_err(); assert!(err.contains("unknown field")); assert!(err.contains("gpu_device_ids")); } + #[test] + fn driver_config_pvc_subpath_mounts_render_in_pod_template() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data-123", + "read_only": false + } + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/memory", + "sub_path": "memory" + } + ] + } + } + }))), + ..SandboxTemplate::default() + }; + let spec = SandboxSpec { + template: Some(template), + ..SandboxSpec::default() + }; + + let cr = sandbox_to_k8s_spec_for_test(Some(&spec), &SandboxPodParams::default()); + let pod_template = &cr["spec"]["podTemplate"]; + + let volumes = pod_template["spec"]["volumes"] + .as_array() + .expect("volumes should exist"); + let user_volume = volumes + .iter() + .find(|volume| volume["name"] == "user-data") + .expect("user PVC volume should be rendered"); + assert_eq!( + user_volume["persistentVolumeClaim"]["claimName"], + "pvc-user-data-123" + ); + assert_eq!(user_volume["persistentVolumeClaim"]["readOnly"], false); + + let mounts = pod_template["spec"]["containers"][0]["volumeMounts"] + .as_array() + .expect("volumeMounts should exist"); + let workspace_mount = mounts + .iter() + .find(|mount| mount["mountPath"] == "/sandbox/.openshell/workspace") + .expect("workspace subPath mount should be rendered"); + assert_eq!(workspace_mount["name"], "user-data"); + assert_eq!(workspace_mount["subPath"], "workspace"); + assert_eq!(workspace_mount["readOnly"], false); + + let memory_mount = mounts + .iter() + .find(|mount| mount["mountPath"] == "/sandbox/.openshell/memory") + .expect("memory subPath mount should be rendered"); + assert_eq!(memory_mount["name"], "user-data"); + assert_eq!(memory_mount["subPath"], "memory"); + assert_eq!(memory_mount["readOnly"], true); + + let spec_obj = cr["spec"].as_object().expect("spec should be an object"); + assert!( + !spec_obj.contains_key("volumeClaimTemplates"), + "explicit /sandbox driver_config mounts should skip the default workspace VCT" + ); + let has_workspace_init = pod_template["spec"]["initContainers"] + .as_array() + .is_some_and(|containers| { + containers + .iter() + .any(|container| container["name"] == WORKSPACE_INIT_CONTAINER_NAME) + }); + assert!( + !has_workspace_init, + "explicit /sandbox driver_config mounts should skip the default workspace init container" + ); + } + + #[test] + fn driver_config_accepts_read_write_pvc_with_multiple_subpath_mounts() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data", + "read_only": false + } + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/memory", + "sub_path": "memory", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/sessions", + "sub_path": "sessions", + "read_only": false + } + ] + } + } + }))), + ..SandboxTemplate::default() + }; + + let config = KubernetesSandboxDriverConfig::from_template(&template) + .expect("read-write PVC with multiple subPath mounts should validate"); + + assert_eq!(config.volumes.len(), 1); + assert_eq!(config.volumes[0].name, "user-data"); + assert_eq!( + config.volumes[0].persistent_volume_claim.claim_name, + "pvc-user-data" + ); + assert!(!config.volumes[0].persistent_volume_claim.read_only); + assert_eq!(config.containers.agent.volume_mounts.len(), 3); + assert!( + config + .containers + .agent + .volume_mounts + .iter() + .all(|mount| !mount.read_only) + ); + assert!(config.has_explicit_sandbox_data_mount()); + } + + #[test] + fn driver_config_rejects_duplicate_pvc_volume_names() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [ + { + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-a"} + }, + { + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-b"} + } + ] + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + + assert!(err.contains("duplicate kubernetes driver_config volume")); + } + + #[test] + fn driver_config_rejects_duplicate_pvc_volume_mount_targets() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace" + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace" + } + ] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + + assert!(err.contains("duplicate kubernetes driver_config mount target")); + } + + #[test] + fn driver_config_accepts_dns1123_subdomain_pvc_claim_name() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc.user-data.123"} + }] + }))), + ..SandboxTemplate::default() + }; + + let config = KubernetesSandboxDriverConfig::from_template(&template) + .expect("DNS-1123 subdomain PVC names should validate"); + + assert_eq!( + config.volumes[0].persistent_volume_claim.claim_name, + "pvc.user-data.123" + ); + } + + #[test] + fn driver_config_rejects_invalid_volume_label_and_claim_name() { + for (field, config) in [ + ( + "volumes[].name", + serde_json::json!({ + "volumes": [{ + "name": "User_Data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }] + }), + ), + ( + "volumes[].persistent_volume_claim.claim_name", + serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "Pvc_User_Data"} + }] + }), + ), + ] { + let template = SandboxTemplate { + driver_config: Some(json_struct(config)), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + assert!( + err.contains(field) && err.contains("DNS-1123"), + "expected invalid {field} to fail DNS-1123 validation, got {err}" + ); + } + } + + #[test] + fn driver_config_rejects_mounts_referencing_unknown_volumes() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "known-data", + "persistent_volume_claim": {"claim_name": "pvc-known"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "missing-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace" + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + + assert!(err.contains("unknown kubernetes driver_config volume 'missing-data'")); + } + + #[test] + fn driver_config_rejects_shared_reserved_mount_targets() { + for mount_path in [ + "/", + "/sandbox", + "/etc/openshell", + "/etc/openshell-tls/client", + "/opt/openshell/bin", + ] { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": mount_path + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + assert!( + err.contains("mount path") || err.contains("mount target"), + "expected protected mount target {mount_path:?} to be rejected, got {err}" + ); + } + } + + #[test] + fn driver_config_rejects_kubernetes_static_protected_mount_targets() { + let spec = SandboxSpec { + template: Some(SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/var/run/secrets/openshell" + }] + } + } + }))), + ..SandboxTemplate::default() + }), + ..SandboxSpec::default() + }; + + let err = kubernetes_driver_config_for_spec(Some(&spec), None).unwrap_err(); + + assert!(err.contains("/var/run/secrets/openshell")); + } + + #[test] + fn driver_config_allows_spiffe_workload_path_without_provider_spiffe() { + let spec = SandboxSpec { + template: Some(SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/spiffe-workload-api" + }] + } + } + }))), + ..SandboxTemplate::default() + }), + ..SandboxSpec::default() + }; + + kubernetes_driver_config_for_spec(Some(&spec), None) + .expect("SPIFFE workload path should only be protected when SPIFFE is enabled"); + } + + #[test] + fn driver_config_rejects_invalid_kubernetes_sub_paths() { + for sub_path in ["/workspace", "../workspace"] { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": sub_path + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + assert!( + err.contains("mount subpath must be relative"), + "expected invalid sub_path {sub_path:?} to be rejected, got {err}" + ); + } + } + + #[test] + fn driver_config_defaults_pvc_mounts_to_read_only() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace" + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let pod_template = sandbox_template_to_k8s( + &template, + false, + &std::collections::HashMap::new(), + false, + &SandboxPodParams::default(), + ); + + let volume = pod_template["spec"]["volumes"] + .as_array() + .expect("volumes should exist") + .iter() + .find(|volume| volume["name"] == "user-data") + .expect("user volume should exist"); + assert_eq!(volume["persistentVolumeClaim"]["readOnly"], true); + + let mount = pod_template["spec"]["containers"][0]["volumeMounts"] + .as_array() + .expect("volumeMounts should exist") + .iter() + .find(|mount| mount["mountPath"] == "/sandbox/.openshell/workspace") + .expect("user mount should exist"); + assert_eq!(mount["readOnly"], true); + } + + #[test] + fn driver_config_rejects_read_write_mount_for_read_only_pvc_volume() { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data", + "read_only": true + } + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "read_only": false + }] + } + } + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + + assert!(err.contains("cannot set read_only=false")); + } + + #[test] + fn driver_config_rejects_reserved_kubernetes_volume_names() { + for volume_name in [ + CLIENT_TLS_VOLUME_NAME, + SERVICE_ACCOUNT_TOKEN_VOLUME_NAME, + SPIFFE_WORKLOAD_API_VOLUME_NAME, + SUPERVISOR_VOLUME_NAME, + WORKSPACE_VOLUME_NAME, + ] { + let template = SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": volume_name, + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }] + }))), + ..SandboxTemplate::default() + }; + + let err = KubernetesSandboxDriverConfig::from_template(&template).unwrap_err(); + assert!( + err.contains("reserved for OpenShell-managed volumes"), + "expected reserved volume name {volume_name:?} to be rejected, got {err}" + ); + } + } + + #[test] + fn reserved_kubernetes_volume_names_cover_managed_pod_volumes() { + let params = SandboxPodParams { + client_tls_secret_name: "openshell-client-tls-secret", + provider_spiffe_enabled: true, + provider_spiffe_workload_api_socket_path: "/spiffe-workload-api/spire-agent.sock", + ..SandboxPodParams::default() + }; + let pod_template = sandbox_template_to_k8s( + &SandboxTemplate::default(), + false, + &std::collections::HashMap::new(), + true, + ¶ms, + ); + let volume_names = pod_template["spec"]["volumes"] + .as_array() + .expect("volumes should exist") + .iter() + .filter_map(|volume| volume["name"].as_str()) + .collect::>(); + + for volume_name in volume_names { + assert!( + KUBERNETES_DRIVER_RESERVED_VOLUME_NAMES.contains(&volume_name), + "managed volume {volume_name:?} should be reserved" + ); + } + } + + #[test] + fn driver_config_rejects_runtime_provider_spiffe_mount_path() { + let spec = SandboxSpec { + template: Some(SandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": {"claim_name": "pvc-user-data"} + }], + "containers": { + "agent": { + "volume_mounts": [{ + "name": "user-data", + "mount_path": "/custom-spiffe" + }] + } + } + }))), + ..SandboxTemplate::default() + }), + ..SandboxSpec::default() + }; + + let err = + kubernetes_driver_config_for_spec(Some(&spec), Some("/custom-spiffe/spire-agent.sock")) + .unwrap_err(); + + assert!(err.contains("/custom-spiffe")); + } + #[test] fn validate_rejects_zero_gpu_count() { let sandbox = Sandbox { @@ -2897,7 +3788,7 @@ mod tests { .expect("volumes should exist"); let tls_vol = volumes .iter() - .find(|v| v["name"] == "openshell-client-tls") + .find(|v| v["name"] == CLIENT_TLS_VOLUME_NAME) .expect("TLS volume should exist"); assert_eq!( tls_vol["secret"]["defaultMode"], @@ -3399,7 +4290,7 @@ mod tests { && volume["csi"]["driver"] == "csi.spiffe.io" })); assert!(volumes.iter().any(|volume| { - volume["name"] == "openshell-sa-token" + volume["name"] == SERVICE_ACCOUNT_TOKEN_VOLUME_NAME && volume["projected"]["sources"][0]["serviceAccountToken"]["path"] == "token" })); @@ -3452,7 +4343,7 @@ mod tests { log_level: "debug".to_string(), ..SandboxSpec::default() }; - let cr = sandbox_to_k8s_spec(Some(&spec), &SandboxPodParams::default()); + let cr = sandbox_to_k8s_spec_for_test(Some(&spec), &SandboxPodParams::default()); let env = cr["spec"]["podTemplate"]["spec"]["containers"][0]["env"] .as_array() .unwrap(); @@ -3479,7 +4370,7 @@ mod tests { )]), ..SandboxSpec::default() }; - let cr = sandbox_to_k8s_spec(Some(&spec), &SandboxPodParams::default()); + let cr = sandbox_to_k8s_spec_for_test(Some(&spec), &SandboxPodParams::default()); let env = cr["spec"]["podTemplate"]["spec"]["containers"][0]["env"] .as_array() .unwrap(); diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index ba47e4eaa..4adb080ed 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -1964,6 +1964,40 @@ mod tests { })); } + #[test] + fn driver_config_rejects_duplicate_mount_targets() { + use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + + let mut sandbox = test_sandbox("test-id", "test-name"); + sandbox.spec = Some(DriverSandboxSpec { + template: Some(DriverSandboxTemplate { + driver_config: Some(json_struct(serde_json::json!({ + "mounts": [ + { + "type": "volume", + "source": "work-nfs", + "target": "/sandbox/work" + }, + { + "type": "tmpfs", + "target": "/sandbox/work" + } + ] + }))), + ..Default::default() + }), + ..Default::default() + }); + let config = test_config(); + + let err = try_build_container_spec_with_token(&sandbox, &config, None).unwrap_err(); + + assert!( + err.to_string() + .contains("duplicate podman driver_config mount target") + ); + } + #[test] fn driver_config_rejects_bind_mounts_unless_enabled() { use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; diff --git a/docs/reference/sandbox-compute-drivers.mdx b/docs/reference/sandbox-compute-drivers.mdx index 341d9e9f4..5394312ce 100644 --- a/docs/reference/sandbox-compute-drivers.mdx +++ b/docs/reference/sandbox-compute-drivers.mdx @@ -315,3 +315,66 @@ If Agent Sandbox is upgraded in place, restart the OpenShell gateway after the c `Sandbox.spec.volumeClaimTemplates` is immutable after creation. To change storage configuration, delete the sandbox and create a new one with the updated spec. + +### Kubernetes Driver Config PVC Mounts + +Kubernetes driver config can mount existing PersistentVolumeClaims into the +agent container. Use this when storage is provisioned outside OpenShell and a +sandbox should mount selected PVC subpaths instead of using the default +OpenShell-created `/sandbox` workspace PVC. + +```shell +openshell sandbox create \ + --driver-config-json '{ + "kubernetes": { + "volumes": [{ + "name": "user-data", + "persistent_volume_claim": { + "claim_name": "pvc-user-data-123", + "read_only": false + } + }], + "containers": { + "agent": { + "volume_mounts": [ + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/workspace", + "sub_path": "workspace", + "read_only": false + }, + { + "name": "user-data", + "mount_path": "/sandbox/.openshell/memory", + "sub_path": "memory", + "read_only": false + } + ] + } + } + } + }' \ + -- claude +``` + +Kubernetes PVC mount schema: + +| Field | Description | +|---|---| +| `volumes[].name` | Pod volume name. It must be a DNS-1123 label, unique, and not use OpenShell-managed volume names. | +| `volumes[].persistent_volume_claim.claim_name` | Existing PVC name in the sandbox namespace. It must be a DNS-1123 subdomain name. | +| `volumes[].persistent_volume_claim.read_only` | Optional. Defaults to `true`. Set `false` to allow read-write mounts. | +| `containers.agent.volume_mounts[].name` | References a volume declared in `volumes`. | +| `containers.agent.volume_mounts[].mount_path` | Absolute, normalized container path for the agent mount. | +| `containers.agent.volume_mounts[].sub_path` | Optional relative PVC subpath. Absolute paths and `..` are rejected. | +| `containers.agent.volume_mounts[].read_only` | Optional. Defaults to `true`. It cannot be `false` when the PVC volume is read-only. | + +OpenShell rejects duplicate volume names, mounts that reference unknown volumes, +protected mount targets, and mounts that replace OpenShell TLS, supervisor, +ServiceAccount token, or SPIFFE paths. Read-write PVC access requires +`read_only: false` on both the PVC volume and each writable mount. + +Any driver-config mount under `/sandbox` disables the default `/sandbox` +workspace PVC injection for that sandbox. Only the explicit mount paths persist +through the external PVC; other `/sandbox` paths come from the current sandbox +image.