Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,35 @@ 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(),
rootfs: Some("alpine:3.20".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
Expand Down
6 changes: 3 additions & 3 deletions examples/custom_template/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion examples/execution_server/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ async fn execute(State(state): State<AppState>, Json(input): Json<ExecuteRequest
let Ok(_permit) = state.slots.clone().try_acquire_owned() else {
return (StatusCode::TOO_MANY_REQUESTS, "execution capacity reached").into_response();
};
let deadline = Instant::now() + Duration::from_secs(120);
let deadline = Instant::now() + Duration::from_mins(2);
let sandbox = match tokio::time::timeout_at(
deadline,
state.client.create_sandbox(CreateSandboxRequest {
Expand Down
8 changes: 4 additions & 4 deletions examples/files_and_snapshots/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ async fn main() -> 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?;
Expand All @@ -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?;
Expand All @@ -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?;
Expand Down
4 changes: 4 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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::<Sandbox>(
&self.transport,
"/v1/sandboxes",
Expand Down
30 changes: 28 additions & 2 deletions src/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ impl Instance {
pub fn ip_address(&self) -> Option<String> {
self.data().ip_address
}
/// Returns the last observed user-defined labels.
pub fn labels(&self) -> std::collections::HashMap<String, String> {
self.data().labels
}
/// Returns a snapshot of the last observed server projection.
pub fn data(&self) -> Sandbox {
self.data
Expand Down Expand Up @@ -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<String, String>,
) -> Result<()> {
#[derive(Serialize)]
struct Body {
labels: std::collections::HashMap<String, String>,
}
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<Duration>) -> Result<()> {
#[derive(Serialize)]
Expand All @@ -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
}) {
Expand Down Expand Up @@ -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
};
Expand Down
9 changes: 6 additions & 3 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ pub struct ListSandboxesOptions {
pub limit: Option<usize>,
/// Optional lifecycle filter.
pub status: Option<SandboxStatus>,
/// Labels that must all match on returned sandboxes.
pub labels: HashMap<String, String>,
}

/// Buffered or streaming command overrides.
Expand Down Expand Up @@ -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(),
}
}
Expand All @@ -250,6 +252,7 @@ CreateSandboxRequest {
shape: String,
#[serde(default, skip_serializing_if = "Option::is_none")] rootfs: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] name: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")] labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] networks: Vec<NetworkEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")] disk_mib: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty", rename = "egress")] egress_rules: Vec<String>,
Expand Down Expand Up @@ -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<String> });
model!(/// Sandbox creation result.
CreateSandboxResponse { id: String, status: SandboxStatus, name: Option<String>, #[serde(rename = "ip")] ip_address: String, shape: String, rootfs: Option<String>, 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<String>, bandwidth_quota_bytes: i64, #[serde(default)] ingress_url_template: String });
CreateSandboxResponse { id: String, status: SandboxStatus, name: Option<String>, #[serde(default)] labels: HashMap<String, String>, #[serde(rename = "ip")] ip_address: String, shape: String, rootfs: Option<String>, 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<String>, 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<String>, vcpu: u32,
#[serde(rename = "mem_mib")] memory_mib: u64, disk_mib: i64, created_at: Option<DateTime<Utc>>, #[serde(default)] ingress_enabled: bool,
#[serde(default)] ingress_url_template: String, name: Option<String>, running_at: Option<DateTime<Utc>>,
#[serde(default)] ingress_url_template: String, name: Option<String>, #[serde(default)] labels: HashMap<String, String>, running_at: Option<DateTime<Utc>>,
destroyed_at: Option<DateTime<Utc>>, #[serde(default, rename = "spawn_ms")] spawn_milliseconds: f64,
#[serde(default)] shape: String, rootfs: Option<String>, #[serde(default)] region: String,
#[serde(default, rename = "egress")] egress_rules: Vec<String>, #[serde(default, rename = "envs")] environment_variables: Vec<String>,
Expand Down
2 changes: 1 addition & 1 deletion src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
36 changes: 27 additions & 9 deletions tests/client.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -58,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::<String>::new()
),
_ => unreachable!(),
}
Expand All @@ -75,6 +72,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(
Expand Down
15 changes: 14 additions & 1 deletion tests/models.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
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() {
let request = CreateSandboxRequest {
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();
assert_eq!(value["shape"], "s-4vcpu-4gb");
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]
Expand Down
Loading