From a4fd68a8531141dfefa4ca1360918baf3596eea4 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Wed, 4 Nov 2026 13:46:26 +0530 Subject: [PATCH] feat: add reusable sandbox snapshots --- CHANGELOG.md | 2 ++ README.md | 21 +++++++++++ examples/snapshots/README.md | 9 +++++ examples/snapshots/main.rs | 68 ++++++++++++++++++++++++++++++++++++ src/client.rs | 7 +++- src/instance.rs | 22 +++++++++--- src/lib.rs | 4 +-- src/models.rs | 27 ++++++++++++-- src/services.rs | 48 ++++++++++++++++++++++++- 9 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 examples/snapshots/README.md create mode 100644 examples/snapshots/main.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d0b4a3..10b6793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/README.md b/README.md index fb200c8..1a60140 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) diff --git a/examples/snapshots/README.md b/examples/snapshots/README.md new file mode 100644 index 0000000..8c69594 --- /dev/null +++ b/examples/snapshots/README.md @@ -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 +``` diff --git a/examples/snapshots/main.rs b/examples/snapshots/main.rs new file mode 100644 index 0000000..79fdca6 --- /dev/null +++ b/examples/snapshots/main.rs @@ -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 +} diff --git a/src/client.rs b/src/client.rs index 51b6dbb..8cd5e96 100644 --- a/src/client.rs +++ b/src/client.rs @@ -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}; @@ -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 { @@ -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), diff --git a/src/instance.rs b/src/instance.rs index b091221..1519ff6 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -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; @@ -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 { + self.transport + .send( + Method::POST, + &self.path("/snapshots"), + &[], + &request, + &RequestOptions::default(), + ) + .await + } async fn lifecycle(&self, suffix: &str) -> Result<()> { let data = self .transport diff --git a/src/lib.rs b/src/lib.rs index c2dc03e..da8482b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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, }; diff --git a/src/models.rs b/src/models.rs index d4c4c44..a3568be 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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. @@ -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 }); 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, #[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, @@ -265,6 +270,11 @@ CreateSandboxRequest { #[serde(default, skip_serializing_if = "Option::is_none")] region: Option, #[serde(default, skip_serializing_if = "Option::is_none")] auto_pause_after_seconds: Option }); +model!(/// Reusable snapshot capture request. +CreateSnapshotRequest { + #[serde(rename = "type")] kind: SnapshotType, + #[serde(default, skip_serializing_if = "Option::is_none")] name: Option +}); model!(/// Optional overrides applied to a sandbox fork. ForkSandboxRequest { #[serde(default, skip_serializing_if = "std::ops::Not::not")] start_paused: bool, @@ -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 }); model!(/// Sandbox creation result. - 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 }); + CreateSandboxResponse { id: String, status: SandboxStatus, snapshot_id: Option, 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, + id: String, status: SandboxStatus, snapshot_id: Option, #[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, #[serde(default)] labels: HashMap, running_at: Option>, destroyed_at: Option>, #[serde(default, rename = "spawn_ms")] spawn_milliseconds: f64, @@ -357,6 +367,17 @@ Sandbox { #[serde(default)] bandwidth_ingress_bytes: i64, paused_at: Option>, last_resumed_at: Option>, forked_from: Option, auto_pause_after_seconds: Option }); +model!(/// Owned reusable sandbox snapshot. +Snapshot { + id: String, sandbox_id: String, name: Option, + #[serde(rename = "type")] kind: SnapshotType, status: SnapshotStatus, + created_at: Option>, ready_at: Option>, + #[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)] diff --git a/src/services.rs b/src/services.rs index 430742c..27bceae 100644 --- a/src/services.rs +++ b/src/services.rs @@ -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, }; @@ -653,6 +654,51 @@ impl DisksService { } } +/// Reusable sandbox snapshot operations. +#[derive(Clone)] +pub struct SnapshotsService { + transport: Arc, +} +impl SnapshotsService { + pub(crate) fn new(transport: Arc) -> Self { + Self { transport } + } + /// Lists reusable snapshots owned by the caller. + pub async fn list(&self, options: PaginationOptions) -> Result> { + 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 { + 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 { + self.transport + .empty( + Method::DELETE, + &format!("/v1/snapshots/{}", encode(id)), + &[], + &RequestOptions::default(), + ) + .await + } +} + /// Desktop computer-use operations. #[derive(Clone)] pub struct ComputerService {