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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable changes to the CreateOS Rust SDK are recorded here.

### Added

- Reusable disk and full snapshot create, get, list, and delete operations,
plus sandbox restoration through `CreateSandboxRequest::snapshot_id`.
- Sandbox labels on create and response models, AND-matched label filtering in
`Client::sandboxes`, and label merging through `Instance::set_labels`.

Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,26 @@ sandbox.set_labels(HashMap::from([
List filters use AND semantics. `set_labels()` merges the supplied keys into
the existing label map.

## Capture and restore snapshots

```rust
let snapshot = sandbox.create_snapshot(CreateSnapshotRequest {
kind: SnapshotType::DISK.into(),
name: Some("configured-workspace".into()),
}).await?;
let snapshot = client.snapshots().get(&snapshot.id).await?; // poll until ready
let restored = client.create_sandbox(CreateSandboxRequest {
snapshot_id: Some(snapshot.id),
..Default::default()
}).await?;
```

Disk snapshots capture the filesystem. Full snapshots also retain memory and
must be restored with their captured CPU, memory, and disk sizes. Snapshot
capture requires a running source sandbox with no attached external disks.
Each account may retain up to five snapshots across both types. See the runnable
[snapshot example](examples/snapshots/README.md).

## Delegate access to one sandbox

The owner can create one delegated token for a sandbox. Creation and rotation
Expand Down Expand Up @@ -445,6 +465,7 @@ Runnable examples live under [`examples/`](examples/):
- [HTTP execution server](examples/execution_server/README.md)
- [Command streaming](examples/command_streaming/README.md)
- [Files and snapshots](examples/files_and_snapshots/README.md)
- [Reusable snapshots](examples/snapshots/README.md)
- [Ingress preview](examples/ingress_preview/README.md)
- [Private overlay network](examples/network/README.md)
- [Custom template](examples/custom_template/README.md)
Expand Down
9 changes: 9 additions & 0 deletions examples/snapshots/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Reusable snapshots

Capture a reusable disk snapshot, wait for it to become ready, and restore a
new sandbox from it.

```bash
export CREATEOS_API_KEY="..."
cargo run --example snapshots
```
68 changes: 68 additions & 0 deletions examples/snapshots/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
use createos::{Client, CreateSandboxRequest, CreateSnapshotRequest, SnapshotStatus, SnapshotType};
use std::time::Duration;

#[tokio::main]
async fn main() -> createos::Result<()> {
let client = Client::from_env()?;
let source = client
.create_sandbox(CreateSandboxRequest {
shape: "s-1vcpu-1gb".into(),
rootfs: Some("alpine:3.20".into()),
..Default::default()
})
.await?;

let mut snapshot_id = None;
let result = async {
let mut snapshot = source
.create_snapshot(CreateSnapshotRequest {
kind: SnapshotType::DISK.into(),
name: Some("configured-workspace".into()),
})
.await?;
snapshot_id = Some(snapshot.id.clone());
let deadline = tokio::time::Instant::now() + Duration::from_secs(300);
while snapshot.status.as_str() == SnapshotStatus::CREATING {
if tokio::time::Instant::now() >= deadline {
return Err(createos::Error::InvalidArgument(
"timed out waiting for snapshot".into(),
));
}
tokio::time::sleep(Duration::from_secs(1)).await;
snapshot = client.snapshots().get(&snapshot.id).await?;
}
if snapshot.status.as_str() != SnapshotStatus::READY {
return Err(createos::Error::InvalidArgument(format!(
"snapshot entered {}",
snapshot.status
)));
}

let restored = client
.create_sandbox(CreateSandboxRequest {
snapshot_id: Some(snapshot.id.clone()),
..Default::default()
})
.await?;
println!(
"restored sandbox {} from snapshot {}",
restored.id(),
snapshot.id
);
restored.destroy().await?;
Ok::<(), createos::Error>(())
}
.await;

source.destroy().await?;
if let Some(id) = snapshot_id {
let snapshot = client.snapshots().get(&id).await?;
if matches!(
snapshot.status.as_str(),
SnapshotStatus::READY | SnapshotStatus::FAILED
) {
client.snapshots().delete(&id).await?;
}
}
result
}
7 changes: 6 additions & 1 deletion src/client.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::{
CreateSandboxRequest, CreateSandboxResponse, DisksService, Error, Health, HostPublic, Instance,
ListSandboxesOptions, NetworksService, Readiness, RequestOptions, Result, RetryOptions,
RootFsData, Sandbox, Shape, TemplatesService, WhoAmI,
RootFsData, Sandbox, Shape, SnapshotsService, TemplatesService, WhoAmI,
transport::{DEFAULT_BASE_URL, Transport},
};
use reqwest::{Method, StatusCode};
Expand Down Expand Up @@ -149,6 +149,10 @@ impl Client {
pub fn disks(&self) -> DisksService {
DisksService::new(self.transport.clone())
}
/// Returns account-level reusable-snapshot operations.
pub fn snapshots(&self) -> SnapshotsService {
SnapshotsService::new(self.transport.clone())
}

/// Returns unauthenticated control-plane liveness.
pub async fn health(&self) -> Result<Health> {
Expand Down Expand Up @@ -216,6 +220,7 @@ impl Client {
let sandbox = Sandbox {
id: created.id,
status: created.status,
snapshot_id: created.snapshot_id,
name: created.name,
labels: created.labels,
ip_address: Some(created.ip_address),
Expand Down
22 changes: 17 additions & 5 deletions src/instance.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use crate::{
AttachDiskOptions, BandwidthView, ComputerService, DetachDiskOptions, DiskAttachment,
DiskDetachedResponse, EgressView, Error, ExecOptions, FilesService, ForkSandboxRequest,
PaginationOptions, ProcessesService, RequestOptions, ResizeSandboxResponse, Result,
RunCommandRequest, RunCommandResponse, Sandbox, SandboxAccessTokenCreateResponse,
SandboxAccessTokenMetadata, SandboxDisk, SandboxStatus, WaitOptions, client::encode,
AttachDiskOptions, BandwidthView, ComputerService, CreateSnapshotRequest, DetachDiskOptions,
DiskAttachment, DiskDetachedResponse, EgressView, Error, ExecOptions, FilesService,
ForkSandboxRequest, PaginationOptions, ProcessesService, RequestOptions, ResizeSandboxResponse,
Result, RunCommandRequest, RunCommandResponse, Sandbox, SandboxAccessTokenCreateResponse,
SandboxAccessTokenMetadata, SandboxDisk, SandboxStatus, Snapshot, WaitOptions, client::encode,
client::fetch_all, transport::Transport,
};
use reqwest::Method;
Expand Down Expand Up @@ -159,6 +159,18 @@ impl Instance {
pub async fn resume(&self) -> Result<()> {
self.lifecycle("/resume").await
}
/// Starts a reusable snapshot capture from this running sandbox.
pub async fn create_snapshot(&self, request: CreateSnapshotRequest) -> Result<Snapshot> {
self.transport
.send(
Method::POST,
&self.path("/snapshots"),
&[],
&request,
&RequestOptions::default(),
)
.await
}
async fn lifecycle(&self, suffix: &str) -> Result<()> {
let data = self
.transport
Expand Down
4 changes: 2 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ pub use instance::{Instance, self_delete, self_pause};
pub use models::*;
pub use services::{
CommandStream, ComputerService, DisksService, FilesService, KeyboardService, MouseService,
NetworksService, ProcessStream, ProcessesService, ScreensService, TemplateLogStream,
TemplatesService, WindowsService,
NetworksService, ProcessStream, ProcessesService, ScreensService, SnapshotsService,
TemplateLogStream, TemplatesService, WindowsService,
};
27 changes: 24 additions & 3 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ SandboxStatus {
RESUMING = "resuming", FORKING = "forking", ERROR = "error", DESTROYING = "destroying",
DESTROYED = "destroyed", FAILED = "failed"
});
string_type!(/// Reusable snapshot capture type.
SnapshotType { DISK = "disk", FULL = "full" });
string_type!(/// Reusable snapshot lifecycle state.
SnapshotStatus { CREATING = "creating", READY = "ready", FAILED = "failed", DELETING = "deleting" });
string_type!(/// Command stream event kind.
ExecStreamEventType { STDOUT = "stdout", STDERR = "stderr", EXIT = "exit", ERROR = "error", HEARTBEAT = "heartbeat" });
string_type!(/// Managed process execution kind.
Expand Down Expand Up @@ -249,7 +253,8 @@ model!(/// Persistent disk attachment.
DiskAttachment { disk_id: String, mount_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] sub_path: Option<String> });
model!(/// Sandbox creation body.
CreateSandboxRequest {
shape: String,
#[serde(default, skip_serializing_if = "String::is_empty")] shape: String,
#[serde(default, skip_serializing_if = "Option::is_none")] snapshot_id: Option<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>,
Expand All @@ -265,6 +270,11 @@ CreateSandboxRequest {
#[serde(default, skip_serializing_if = "Option::is_none")] region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] auto_pause_after_seconds: Option<u64>
});
model!(/// Reusable snapshot capture request.
CreateSnapshotRequest {
#[serde(rename = "type")] kind: SnapshotType,
#[serde(default, skip_serializing_if = "Option::is_none")] name: Option<String>
});
model!(/// Optional overrides applied to a sandbox fork.
ForkSandboxRequest {
#[serde(default, skip_serializing_if = "std::ops::Not::not")] start_paused: bool,
Expand Down Expand Up @@ -344,10 +354,10 @@ 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(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 });
CreateSandboxResponse { id: String, status: SandboxStatus, snapshot_id: Option<String>, 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,
id: String, status: SandboxStatus, snapshot_id: Option<String>, #[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>, #[serde(default)] labels: HashMap<String, String>, running_at: Option<DateTime<Utc>>,
destroyed_at: Option<DateTime<Utc>>, #[serde(default, rename = "spawn_ms")] spawn_milliseconds: f64,
Expand All @@ -357,6 +367,17 @@ Sandbox {
#[serde(default)] bandwidth_ingress_bytes: i64, paused_at: Option<DateTime<Utc>>, last_resumed_at: Option<DateTime<Utc>>,
forked_from: Option<String>, auto_pause_after_seconds: Option<u64>
});
model!(/// Owned reusable sandbox snapshot.
Snapshot {
id: String, sandbox_id: String, name: Option<String>,
#[serde(rename = "type")] kind: SnapshotType, status: SnapshotStatus,
created_at: Option<DateTime<Utc>>, ready_at: Option<DateTime<Utc>>,
#[serde(default)] shape: String, #[serde(default)] vcpu: u32,
#[serde(default, rename = "mem_mib")] memory_mib: u64,
#[serde(default)] disk_mib: i64, #[serde(default)] size_bytes: i64
});
model!(/// Accepted reusable snapshot deletion.
SnapshotDeletedResponse { id: String, status: SnapshotStatus });

/// Plaintext delegated token returned only when created or rotated.
#[derive(Clone, Deserialize)]
Expand Down
48 changes: 47 additions & 1 deletion src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use crate::{
ManagedProcessConnectEvent, ManagedProcessConnectFrame, ManagedProcessConnectOptions,
ManagedProcessCreateRequest, ManagedProcessDeleteOptions, ManagedProcessSignal,
ManagedProcessWaitOptions, Network, NetworkCreateRequest, PaginationOptions, PtySize,
RequestOptions, Result, Template, TemplateLogEvent, TemplateLogsOptions,
RequestOptions, Result, Snapshot, SnapshotDeletedResponse, Template, TemplateLogEvent,
TemplateLogsOptions,
client::{encode, fetch_all},
transport::Transport,
};
Expand Down Expand Up @@ -653,6 +654,51 @@ impl DisksService {
}
}

/// Reusable sandbox snapshot operations.
#[derive(Clone)]
pub struct SnapshotsService {
transport: Arc<Transport>,
}
impl SnapshotsService {
pub(crate) fn new(transport: Arc<Transport>) -> Self {
Self { transport }
}
/// Lists reusable snapshots owned by the caller.
pub async fn list(&self, options: PaginationOptions) -> Result<Vec<Snapshot>> {
fetch_all(
&self.transport,
"/v1/snapshots",
offset_query(options.offset),
options.limit,
Some("snapshots"),
&RequestOptions::default(),
)
.await
}
/// Gets an owned reusable snapshot by ID.
pub async fn get(&self, id: &str) -> Result<Snapshot> {
self.transport
.get(
&format!("/v1/snapshots/{}", encode(id)),
&[],
&RequestOptions::default(),
true,
)
.await
}
/// Removes an owned ready or failed snapshot from API listings.
pub async fn delete(&self, id: &str) -> Result<SnapshotDeletedResponse> {
self.transport
.empty(
Method::DELETE,
&format!("/v1/snapshots/{}", encode(id)),
&[],
&RequestOptions::default(),
)
.await
}
}

/// Desktop computer-use operations.
#[derive(Clone)]
pub struct ComputerService {
Expand Down
Loading