From 348c4fb8b812a76e1e6a1bd81f2d4387c3e6b65e Mon Sep 17 00:00:00 2001 From: Gal Zaidman Date: Wed, 1 Jul 2026 20:52:32 +0300 Subject: [PATCH] fix(server): apply sandbox labels to the compute resource Request labels were stored only as sandbox metadata and never reached the pod or container. Propagate them onto the sandbox template so drivers tag the compute resource; explicit template labels win on key conflicts. Reject the driver-managed `openshell.ai/` label namespace and validate the label count in the gateway so the merged template map stays bounded. Signed-off-by: Gal Zaidman --- crates/openshell-core/src/driver_utils.rs | 3 + crates/openshell-server/src/grpc/sandbox.rs | 118 +++++++++++++++++- .../openshell-server/src/grpc/validation.rs | 49 ++++++++ docs/sandboxes/manage-sandboxes.mdx | 2 + 4 files changed, 167 insertions(+), 5 deletions(-) diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index 9e4411b2a..b43f45c9a 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -11,6 +11,9 @@ use crate::proto::compute::v1::{DriverSandbox, GetCapabilitiesResponse}; // Sandbox container/pod label keys (openshell.ai/ namespace) // --------------------------------------------------------------------------- +/// Reserved label key namespace owned by `OpenShell`. +pub const LABEL_RESERVED_PREFIX: &str = "openshell.ai/"; + /// Container/pod label that identifies this resource as managed by `OpenShell`. /// Value should be `"openshell"`. pub const LABEL_MANAGED_BY: &str = "openshell.ai/managed-by"; diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 04d5a4ed5..5ab81b6ef 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -128,11 +128,9 @@ async fn handle_create_sandbox_inner( // Validate field sizes before any I/O (fail fast on oversized payloads). validate_sandbox_spec(&request.name, &spec)?; - // Validate labels (keys and values must meet Kubernetes requirements). - for (key, value) in &request.labels { - crate::grpc::validation::validate_label_key(key)?; - crate::grpc::validation::validate_label_value(value)?; - } + // Validate labels (count, keys, and values). Bounding the count here keeps + // the merge onto `template.labels` below within the template map limit. + crate::grpc::validation::validate_labels(&request.labels)?; let _sandbox_sync_guard = if spec.providers.is_empty() { None @@ -158,6 +156,14 @@ async fn handle_create_sandbox_inner( template.image = state.compute.default_image().to_string(); } + // Propagate request labels onto the template + for (key, value) in &request.labels { + template + .labels + .entry(key.clone()) + .or_insert_with(|| value.clone()); + } + // Ensure process identity defaults to "sandbox" when missing or // empty, then validate policy safety before persisting. if let Some(ref mut policy) = spec.policy { @@ -2727,6 +2733,108 @@ mod tests { ); } + #[tokio::test] + async fn create_sandbox_propagates_labels_to_pod_template() { + let state = test_server_state().await; + + let response = handle_create_sandbox( + &state, + Request::new(CreateSandboxRequest { + name: "labeled".to_string(), + spec: Some(openshell_core::proto::SandboxSpec::default()), + labels: std::iter::once(("team".to_string(), "platform".to_string())).collect(), + }), + ) + .await + .expect("create should succeed") + .into_inner(); + + let sandbox = response.sandbox.unwrap(); + // Stored as sandbox metadata (used for CLI filtering). + assert_eq!( + sandbox + .metadata + .as_ref() + .unwrap() + .labels + .get("team") + .map(String::as_str), + Some("platform"), + ); + // Propagated to the template so the driver applies it to the pod. + assert_eq!( + sandbox + .spec + .unwrap() + .template + .unwrap() + .labels + .get("team") + .map(String::as_str), + Some("platform"), + ); + } + + #[tokio::test] + async fn create_sandbox_rejects_reserved_label_namespace() { + let state = test_server_state().await; + + let status = handle_create_sandbox( + &state, + Request::new(CreateSandboxRequest { + name: "reserved-label".to_string(), + spec: Some(openshell_core::proto::SandboxSpec::default()), + labels: std::iter::once(( + "openshell.ai/sandbox-id".to_string(), + "spoofed".to_string(), + )) + .collect(), + }), + ) + .await + .expect_err("reserved label namespace should be rejected"); + + assert_eq!(status.code(), tonic::Code::InvalidArgument); + } + + #[tokio::test] + async fn create_sandbox_template_labels_take_precedence_over_request_labels() { + let state = test_server_state().await; + + let template = SandboxTemplate { + labels: std::iter::once(("team".to_string(), "explicit".to_string())).collect(), + ..Default::default() + }; + let response = handle_create_sandbox( + &state, + Request::new(CreateSandboxRequest { + name: "labeled-precedence".to_string(), + spec: Some(openshell_core::proto::SandboxSpec { + template: Some(template), + ..Default::default() + }), + labels: std::iter::once(("team".to_string(), "platform".to_string())).collect(), + }), + ) + .await + .expect("create should succeed") + .into_inner(); + + assert_eq!( + response + .sandbox + .unwrap() + .spec + .unwrap() + .template + .unwrap() + .labels + .get("team") + .map(String::as_str), + Some("explicit"), + ); + } + #[tokio::test] async fn attach_sandbox_provider_rejects_credential_key_collisions() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 09e9f1cad..eb5bcd889 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -396,6 +396,13 @@ pub(super) fn validate_label_key(key: &str) -> Result<(), Status> { return Err(Status::invalid_argument("label key cannot be empty")); } + if key.starts_with(openshell_core::driver_utils::LABEL_RESERVED_PREFIX) { + return Err(Status::invalid_argument(format!( + "label key '{key}' uses the reserved '{}' namespace", + openshell_core::driver_utils::LABEL_RESERVED_PREFIX + ))); + } + if key.len() > 253 { return Err(Status::invalid_argument(format!( "label key exceeds 253 characters: '{key}'" @@ -540,6 +547,24 @@ pub(super) fn validate_label_value(value: &str) -> Result<(), Status> { Ok(()) } +/// Validate a label map: entry count (bounded by [`MAX_TEMPLATE_MAP_ENTRIES`]), +/// then each key and value. +pub(super) fn validate_labels( + labels: &std::collections::HashMap, +) -> Result<(), Status> { + if labels.len() > MAX_TEMPLATE_MAP_ENTRIES { + return Err(Status::invalid_argument(format!( + "labels exceeds maximum entries ({} > {MAX_TEMPLATE_MAP_ENTRIES})", + labels.len() + ))); + } + for (key, value) in labels { + validate_label_key(key)?; + validate_label_value(value)?; + } + Ok(()) +} + /// Validate a label selector string format. /// /// Format: "key1=value1,key2=value2" @@ -1274,6 +1299,30 @@ mod tests { assert!(err.message().contains("cannot be empty")); } + #[test] + fn validate_label_key_rejects_reserved_namespace() { + let err = validate_label_key("openshell.ai/sandbox-id").unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("reserved")); + } + + #[test] + fn validate_labels_accepts_valid_map() { + let labels: HashMap = + std::iter::once(("team".to_string(), "platform".to_string())).collect(); + assert!(validate_labels(&labels).is_ok()); + } + + #[test] + fn validate_labels_rejects_too_many_entries() { + let labels: HashMap = (0..=MAX_TEMPLATE_MAP_ENTRIES) + .map(|i| (format!("k{i}"), "v".to_string())) + .collect(); + let err = validate_labels(&labels).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("labels")); + } + #[test] fn validate_label_key_rejects_name_starting_with_hyphen() { let err = validate_label_key("-app").unwrap_err(); diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index fdbf497f5..b002ea063 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -214,6 +214,8 @@ Attach labels when you create a sandbox to track ownership, environment, or work openshell sandbox create --label env=dev --label team=platform -- claude ``` +Labels are stored as sandbox metadata for filtering and are also applied to the underlying compute resource (the Kubernetes pod or Docker container) so you can select sandboxes with native platform tooling. The VM driver stores labels as metadata only. The `openshell.ai/` key namespace is reserved for driver-managed labels and is rejected on create. + List only the sandboxes that match a label selector: ```shell