Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/openshell-core/src/driver_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
118 changes: 113 additions & 5 deletions crates/openshell-server/src/grpc/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
49 changes: 49 additions & 0 deletions crates/openshell-server/src/grpc/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}'"
Expand Down Expand Up @@ -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<String, String>,
) -> 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"
Expand Down Expand Up @@ -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<String, String> =
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<String, String> = (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();
Expand Down
2 changes: 2 additions & 0 deletions docs/sandboxes/manage-sandboxes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading