From e5f685900e271e6b2a9a2e6676350c9ac2708482 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Fri, 23 Oct 2026 15:40:41 +0530 Subject: [PATCH 1/3] feat: add sandbox labels support --- CHANGELOG.md | 5 +++++ README.md | 28 ++++++++++++++++++++++++++ examples/custom_template/main.rs | 6 +++--- examples/execution_server/main.rs | 2 +- examples/files_and_snapshots/main.rs | 8 ++++---- src/client.rs | 4 ++++ src/instance.rs | 30 ++++++++++++++++++++++++++-- src/models.rs | 9 ++++++--- src/transport.rs | 2 +- tests/client.rs | 24 +++++++++++++++++++++- tests/models.rs | 15 +++++++++++++- 11 files changed, 117 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c515523..3d0b4a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to the CreateOS Rust SDK are recorded here. ## Unreleased +### Added + +- Sandbox labels on create and response models, AND-matched label filtering in + `Client::sandboxes`, and label merging through `Instance::set_labels`. + ### Fixed - Redact plaintext sandbox access tokens from debug output to prevent diff --git a/README.md b/README.md index 860ab94..9232890 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,34 @@ Use HTTPS for every non-loopback endpoint. For custom proxy or TLS settings, pass a `reqwest::ClientBuilder` to `Client::builder().http_client(...)`; the SDK still disables redirects so credentials cannot be forwarded to another origin. +## Organize sandboxes with labels + +```rust +use createos::{CreateSandboxRequest, ListSandboxesOptions}; +use std::collections::HashMap; + +let sandbox = client.create_sandbox(CreateSandboxRequest { + shape: "s-1vcpu-1gb".into(), + labels: HashMap::from([ + ("team".into(), "backend".into()), + ("environment".into(), "test".into()), + ]), + ..Default::default() +}).await?; + +let matches = client.sandboxes(ListSandboxesOptions { + labels: HashMap::from([("team".into(), "backend".into())]), + ..Default::default() +}).await?; + +sandbox.set_labels(HashMap::from([ + ("environment".into(), "production".into()), +])).await?; +``` + +List filters use AND semantics. `set_labels()` merges the supplied keys into +the existing label map. + ## Delegate access to one sandbox The owner can create one delegated token for a sandbox. Creation and rotation diff --git a/examples/custom_template/main.rs b/examples/custom_template/main.rs index e1e6024..2f5c3fd 100644 --- a/examples/custom_template/main.rs +++ b/examples/custom_template/main.rs @@ -31,7 +31,7 @@ async fn main() -> createos::Result<()> { let result = async { let options = TemplateLogsOptions { request: RequestOptions { - timeout: Some(Duration::from_secs(600)), + timeout: Some(Duration::from_mins(10)), ..Default::default() }, ..Default::default() @@ -106,7 +106,7 @@ async fn main() -> createos::Result<()> { tokio::time::sleep(Duration::from_secs(2)).await; } if !ready { - return Err(createos::Error::Timeout(Duration::from_secs(60))); + return Err(createos::Error::Timeout(Duration::from_mins(1))); } let response = sandbox .run_command( @@ -117,7 +117,7 @@ async fn main() -> createos::Result<()> { }, ExecOptions { request: RequestOptions { - timeout: Some(Duration::from_secs(120)), + timeout: Some(Duration::from_mins(2)), ..Default::default() }, ..Default::default() diff --git a/examples/execution_server/main.rs b/examples/execution_server/main.rs index e6952f8..1767b39 100644 --- a/examples/execution_server/main.rs +++ b/examples/execution_server/main.rs @@ -46,7 +46,7 @@ async fn execute(State(state): State, Json(input): Json createos::Result<()> { println!("wrote {BASE_PATH}: {}", read_file(&base, BASE_PATH).await?); base.pause().await?; base.wait_until_paused(WaitOptions { - timeout: Duration::from_secs(600), + timeout: Duration::from_mins(10), ..Default::default() }) .await?; @@ -58,13 +58,13 @@ async fn main() -> createos::Result<()> { println!("fork created: {}", fork.id()); let fork_result = async { fork.wait_until_paused(WaitOptions { - timeout: Duration::from_secs(600), + timeout: Duration::from_mins(10), ..Default::default() }) .await?; fork.resume().await?; fork.wait_until_running(WaitOptions { - timeout: Duration::from_secs(300), + timeout: Duration::from_mins(5), ..Default::default() }) .await?; @@ -82,7 +82,7 @@ async fn main() -> createos::Result<()> { base.resume().await?; base.wait_until_running(WaitOptions { - timeout: Duration::from_secs(300), + timeout: Duration::from_mins(5), ..Default::default() }) .await?; diff --git a/src/client.rs b/src/client.rs index fc85a2c..51b6dbb 100644 --- a/src/client.rs +++ b/src/client.rs @@ -217,6 +217,7 @@ impl Client { id: created.id, status: created.status, name: created.name, + labels: created.labels, ip_address: Some(created.ip_address), shape: created.shape, rootfs: created.rootfs, @@ -256,6 +257,9 @@ impl Client { if let Some(status) = options.status { query.push(("status".into(), status.to_string())); } + if !options.labels.is_empty() { + query.push(("labels".into(), serde_json::to_string(&options.labels)?)); + } Ok(fetch_all::( &self.transport, "/v1/sandboxes", diff --git a/src/instance.rs b/src/instance.rs index 4ffb4e2..b091221 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -44,6 +44,10 @@ impl Instance { pub fn ip_address(&self) -> Option { self.data().ip_address } + /// Returns the last observed user-defined labels. + pub fn labels(&self) -> std::collections::HashMap { + self.data().labels + } /// Returns a snapshot of the last observed server projection. pub fn data(&self) -> Sandbox { self.data @@ -244,6 +248,28 @@ impl Instance { self.update(data); Ok(()) } + /// Merges labels into the sandbox's existing labels. + pub async fn set_labels( + &self, + labels: std::collections::HashMap, + ) -> Result<()> { + #[derive(Serialize)] + struct Body { + labels: std::collections::HashMap, + } + let data = self + .transport + .send( + Method::PATCH, + &self.path(""), + &[], + &Body { labels }, + &RequestOptions::default(), + ) + .await?; + self.update(data); + Ok(()) + } /// Sets the idle timeout. `None` disables auto-pause. pub async fn set_auto_pause(&self, timeout: Option) -> Result<()> { #[derive(Serialize)] @@ -253,7 +279,7 @@ impl Instance { disable_auto_pause: bool, } if timeout.is_some_and(|value| { - value < Duration::from_secs(60) + value < Duration::from_mins(1) || value > Duration::from_hours(24) || value.subsec_nanos() != 0 }) { @@ -486,7 +512,7 @@ impl Instance { } async fn wait_for(&self, desired: &str, terminal: &[&str], options: WaitOptions) -> Result<()> { let timeout = if options.timeout.is_zero() { - Duration::from_secs(120) + Duration::from_mins(2) } else { options.timeout }; diff --git a/src/models.rs b/src/models.rs index 36375ed..d4c4c44 100644 --- a/src/models.rs +++ b/src/models.rs @@ -117,6 +117,8 @@ pub struct ListSandboxesOptions { pub limit: Option, /// Optional lifecycle filter. pub status: Option, + /// Labels that must all match on returned sandboxes. + pub labels: HashMap, } /// Buffered or streaming command overrides. @@ -223,7 +225,7 @@ pub struct WaitOptions { impl Default for WaitOptions { fn default() -> Self { Self { - timeout: Duration::from_secs(120), + timeout: Duration::from_mins(2), request: RequestOptions::default(), } } @@ -250,6 +252,7 @@ CreateSandboxRequest { shape: String, #[serde(default, skip_serializing_if = "Option::is_none")] rootfs: Option, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, + #[serde(default, skip_serializing_if = "HashMap::is_empty")] labels: HashMap, #[serde(default, skip_serializing_if = "Vec::is_empty")] networks: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] disk_mib: Option, #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "egress")] egress_rules: Vec, @@ -341,12 +344,12 @@ model!(/// Built-in root filesystem catalog. model!(/// Public worker-host projection. HostPublic { id: String, status: HostStatus, #[serde(rename = "free_mib")] free_memory_mib: i64, #[serde(rename = "vm_count")] sandbox_count: u64, #[serde(default, rename = "rootfses")] root_file_systems: Vec }); model!(/// Sandbox creation result. - CreateSandboxResponse { id: String, status: SandboxStatus, name: Option, #[serde(rename = "ip")] ip_address: String, shape: String, rootfs: Option, vcpu: u32, #[serde(rename = "mem_mib")] memory_mib: u64, disk_mib: i64, #[serde(rename = "spawn_ms")] spawn_milliseconds: f64, #[serde(default, rename = "egress")] egress_rules: Vec, bandwidth_quota_bytes: i64, #[serde(default)] ingress_url_template: String }); + CreateSandboxResponse { id: String, status: SandboxStatus, name: Option, #[serde(default)] labels: HashMap, #[serde(rename = "ip")] ip_address: String, shape: String, rootfs: Option, vcpu: u32, #[serde(rename = "mem_mib")] memory_mib: u64, disk_mib: i64, #[serde(rename = "spawn_ms")] spawn_milliseconds: f64, #[serde(default, rename = "egress")] egress_rules: Vec, bandwidth_quota_bytes: i64, #[serde(default)] ingress_url_template: String }); model!(/// Full sandbox projection. Sandbox { id: String, status: SandboxStatus, #[serde(rename = "ip")] ip_address: Option, vcpu: u32, #[serde(rename = "mem_mib")] memory_mib: u64, disk_mib: i64, created_at: Option>, #[serde(default)] ingress_enabled: bool, - #[serde(default)] ingress_url_template: String, name: Option, running_at: Option>, + #[serde(default)] ingress_url_template: String, name: Option, #[serde(default)] labels: HashMap, running_at: Option>, destroyed_at: Option>, #[serde(default, rename = "spawn_ms")] spawn_milliseconds: f64, #[serde(default)] shape: String, rootfs: Option, #[serde(default)] region: String, #[serde(default, rename = "egress")] egress_rules: Vec, #[serde(default, rename = "envs")] environment_variables: Vec, diff --git a/src/transport.rs b/src/transport.rs index 6549e47..7669f76 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -7,7 +7,7 @@ use std::{sync::Arc, time::Duration}; use url::Url; pub(crate) const DEFAULT_BASE_URL: &str = "https://api.sb.createos.sh"; -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +const DEFAULT_TIMEOUT: Duration = Duration::from_mins(1); #[derive(Clone)] pub(crate) struct Transport { diff --git a/tests/client.rs b/tests/client.rs index d5eb4ee..80f078c 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -1,5 +1,6 @@ -use createos::{Client, CreateSandboxRequest, Error, RequestOptions}; +use createos::{Client, CreateSandboxRequest, Error, ListSandboxesOptions, RequestOptions}; use std::{ + collections::HashMap, io::{Read as _, Write as _}, net::TcpListener, sync::mpsc, @@ -75,6 +76,27 @@ async fn public_catalog_and_readiness_endpoints_omit_authentication() { } } +#[tokio::test] +async fn sandbox_list_encodes_label_filter() { + let (base_url, request) = serve_once( + "200 OK", + r#"{"status":"success","data":{"data":[],"pagination":{"total":0}}}"#, + ); + let client = Client::builder() + .base_url(base_url) + .api_key("secret") + .build() + .unwrap(); + let options = ListSandboxesOptions { + labels: HashMap::from([("team".into(), "backend".into())]), + ..Default::default() + }; + + assert!(client.sandboxes(options).await.unwrap().is_empty()); + let request = request.recv().unwrap(); + assert!(request.contains("labels=%7B%22team%22%3A%22backend%22%7D")); +} + #[tokio::test] async fn api_errors_remain_inspectable() { let (base_url, request) = serve_once( diff --git a/tests/models.rs b/tests/models.rs index 3e4a961..f77d7f0 100644 --- a/tests/models.rs +++ b/tests/models.rs @@ -1,7 +1,8 @@ use createos::{ ComputerButtonRequest, ComputerCreateScreenRequest, ComputerScrollRequest, - CreateSandboxRequest, PtySize, SandboxStatus, TemplateLogEvent, + CreateSandboxRequest, PtySize, Sandbox, SandboxStatus, TemplateLogEvent, }; +use std::collections::HashMap; #[test] fn request_uses_api_wire_names_and_omits_defaults() { @@ -9,6 +10,7 @@ fn request_uses_api_wire_names_and_omits_defaults() { shape: "s-4vcpu-4gb".into(), ingress_enabled: true, ssh_public_keys: vec!["ssh-ed25519 example".into()], + labels: HashMap::from([("team".into(), "backend".into())]), ..Default::default() }; let value = serde_json::to_value(request).unwrap(); @@ -16,6 +18,17 @@ fn request_uses_api_wire_names_and_omits_defaults() { assert_eq!(value["ingress_enabled"], true); assert!(value.get("ssh_public_keys").is_none()); assert_eq!(value["ssh_pubkeys"][0], "ssh-ed25519 example"); + assert_eq!(value["labels"]["team"], "backend"); +} + +#[test] +fn sandbox_response_decodes_labels() { + let sandbox: Sandbox = serde_json::from_str( + r#"{"id":"sb-1","status":"running","labels":{"team":"backend"},"vcpu":1,"mem_mib":256,"disk_mib":1024}"#, + ) + .unwrap(); + + assert_eq!(sandbox.labels["team"], "backend"); } #[test] From e35b028e89418fe943814c05580edeb21810f92d Mon Sep 17 00:00:00 2001 From: Bhautik Date: Fri, 23 Oct 2026 16:02:48 +0530 Subject: [PATCH 2/3] docs: make labels example runnable --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9232890..fb200c8 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ use std::collections::HashMap; let sandbox = client.create_sandbox(CreateSandboxRequest { shape: "s-1vcpu-1gb".into(), + rootfs: Some("alpine:3.20".into()), labels: HashMap::from([ ("team".into(), "backend".into()), ("environment".into(), "test".into()), From 7293e457d6b8b7e5168e213452b690146c679595 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Fri, 23 Oct 2026 16:25:08 +0530 Subject: [PATCH 3/3] test: satisfy Rust 1.99 clippy --- tests/client.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/client.rs b/tests/client.rs index 80f078c..33e7213 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -59,14 +59,10 @@ async fn public_catalog_and_readiness_endpoints_omit_authentication() { .unwrap(); match path { "/readyz" => assert!(client.readiness().await.unwrap().ready), - "/v1/shapes" => assert!(client.shapes().await.unwrap().is_empty()), - "/v1/rootfs" => assert!( - client - .root_file_systems() - .await - .unwrap() - .root_file_systems - .is_empty() + "/v1/shapes" => assert_eq!(client.shapes().await.unwrap(), Vec::new()), + "/v1/rootfs" => assert_eq!( + client.root_file_systems().await.unwrap().root_file_systems, + Vec::::new() ), _ => unreachable!(), }