From 16bcb248422ccc183790cf8f949669abff8d3c58 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Thu, 5 Nov 2026 23:33:03 +0530 Subject: [PATCH] feat: add checkpoints --- CHANGELOG.md | 4 +- Cargo.toml | 2 +- README.md | 19 ++++ examples/checkpoints/README.md | 11 ++ examples/checkpoints/main.rs | 40 ++++++++ src/instance.rs | 16 +-- src/lib.rs | 6 +- src/models.rs | 12 +++ src/services.rs | 177 ++++++++++++++++++++++++++++++--- 9 files changed, 264 insertions(+), 23 deletions(-) create mode 100644 examples/checkpoints/README.md create mode 100644 examples/checkpoints/main.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 10b6793..0c933ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to the CreateOS Rust SDK are recorded here. ### Added +- Checkpoint create, list, restore, and delete operations through + `Instance::checkpoints`. - 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 @@ -23,4 +25,4 @@ All notable changes to the CreateOS Rust SDK are recorded here. ### Changed -- Set the next crate version to `0.1.1`; the default user agent follows the crate version. +- Set the next crate version to `0.1.2`; the default user agent follows the crate version. diff --git a/Cargo.toml b/Cargo.toml index 666686b..9a67075 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "createos" -version = "0.1.1" +version = "0.1.2" edition = "2024" rust-version = "1.98" description = "Async Rust SDK for CreateOS cloud sandboxes" diff --git a/README.md b/README.md index 1a60140..03a755a 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,24 @@ 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). +## Checkpoint a directory + +```rust +let checkpoint = sandbox.checkpoints().create(CreateCheckpointRequest { + path: "/workspace/project".into(), + name: Some("before-update".into()), +}).await?; + +sandbox.checkpoints().restore(&checkpoint.id).await?; +sandbox.checkpoints().delete(&checkpoint.id).await?; +``` + +Checkpoints provide local file history inside one running sandbox. +They restore to the original path, do not capture memory, and disappear with +the sandbox. System directories, mount points, overlapping paths, and paths +containing special files or hard links are rejected. See the runnable +[checkpoint example](examples/checkpoints/README.md). + ## Delegate access to one sandbox The owner can create one delegated token for a sandbox. Creation and rotation @@ -466,6 +484,7 @@ Runnable examples live under [`examples/`](examples/): - [Command streaming](examples/command_streaming/README.md) - [Files and snapshots](examples/files_and_snapshots/README.md) - [Reusable snapshots](examples/snapshots/README.md) +- [Checkpoints](examples/checkpoints/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/checkpoints/README.md b/examples/checkpoints/README.md new file mode 100644 index 0000000..b7d2e91 --- /dev/null +++ b/examples/checkpoints/README.md @@ -0,0 +1,11 @@ +# Checkpoints + +Create a sandbox, checkpoint one directory, modify it, restore the checkpoint, +and delete the checkpoint: + +```bash +export CREATEOS_API_KEY=skp_... +cargo run --example checkpoints +``` + +The example always destroys its sandbox before exiting. diff --git a/examples/checkpoints/main.rs b/examples/checkpoints/main.rs new file mode 100644 index 0000000..20210db --- /dev/null +++ b/examples/checkpoints/main.rs @@ -0,0 +1,40 @@ +use createos::{Client, CreateCheckpointRequest, CreateSandboxRequest}; + +#[tokio::main] +async fn main() -> createos::Result<()> { + let client = Client::from_env()?; + let sandbox = client + .create_sandbox(CreateSandboxRequest { + shape: "s-1vcpu-1gb".into(), + rootfs: Some("devbox:1".into()), + ..Default::default() + }) + .await?; + + let result = async { + sandbox + .shell("mkdir -p /root/project && printf version-1 > /root/project/version") + .await?; + let checkpoint = sandbox + .checkpoints() + .create(CreateCheckpointRequest { + path: "/root/project".into(), + name: Some("before-update".into()), + }) + .await?; + + sandbox + .shell("printf version-2 > /root/project/version") + .await?; + sandbox.checkpoints().restore(&checkpoint.id).await?; + let restored = sandbox.shell("cat /root/project/version").await?; + println!("restored {}", restored.result.standard_output.trim()); + + sandbox.checkpoints().delete(&checkpoint.id).await + } + .await; + + let cleanup = sandbox.destroy().await; + result?; + cleanup +} diff --git a/src/instance.rs b/src/instance.rs index 1519ff6..00fe575 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -1,10 +1,10 @@ use crate::{ - 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, + AttachDiskOptions, BandwidthView, CheckpointsService, 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; use serde::Serialize; @@ -67,6 +67,10 @@ impl Instance { pub fn computer(&self) -> ComputerService { ComputerService::new(self.clone()) } + /// Returns checkpoint operations. + pub fn checkpoints(&self) -> CheckpointsService { + CheckpointsService::new(self.clone()) + } /// Returns a separate handle that authenticates with a delegated sandbox token. /// diff --git a/src/lib.rs b/src/lib.rs index da8482b..e58b7f2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,7 @@ pub use error::{ApiError, CommandError, Error, Result}; pub use instance::{Instance, self_delete, self_pause}; pub use models::*; pub use services::{ - CommandStream, ComputerService, DisksService, FilesService, KeyboardService, MouseService, - NetworksService, ProcessStream, ProcessesService, ScreensService, SnapshotsService, - TemplateLogStream, TemplatesService, WindowsService, + CheckpointsService, CommandStream, ComputerService, DisksService, FilesService, + KeyboardService, MouseService, NetworksService, ProcessStream, ProcessesService, + ScreensService, SnapshotsService, TemplateLogStream, TemplatesService, WindowsService, }; diff --git a/src/models.rs b/src/models.rs index a3568be..9f4194a 100644 --- a/src/models.rs +++ b/src/models.rs @@ -275,6 +275,11 @@ CreateSnapshotRequest { #[serde(rename = "type")] kind: SnapshotType, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option }); +model!(/// Checkpoint creation request. +CreateCheckpointRequest { + path: String, + #[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, @@ -378,6 +383,13 @@ Snapshot { }); model!(/// Accepted reusable snapshot deletion. SnapshotDeletedResponse { id: String, status: SnapshotStatus }); +model!(/// Point-in-time checkpoint of one sandbox directory. +Checkpoint { + id: String, path: String, name: Option, parent_id: Option, + created_at: DateTime +}); +model!(/// Result of restoring a checkpoint. + CheckpointRestoreResponse { checkpoint_id: String, path: String }); /// Plaintext delegated token returned only when created or rotated. #[derive(Clone, Deserialize)] diff --git a/src/services.rs b/src/services.rs index 27bceae..685df69 100644 --- a/src/services.rs +++ b/src/services.rs @@ -1,16 +1,16 @@ use crate::{ - ApiError, ComputerButtonRequest, ComputerClickRequest, ComputerCreateScreenRequest, - ComputerDragRequest, ComputerLaunchRequest, ComputerListWindowsOptions, ComputerOpenRequest, - ComputerPoint, ComputerScreen, ComputerScreenConnection, ComputerScreenGeometry, - ComputerScreenId, ComputerScreenOptions, ComputerScreenshotOptions, ComputerScrollRequest, - ComputerTypeRequest, ComputerWindow, ComputerWindowGeometry, ComputerWindowMoveRequest, - ComputerWindowResizeRequest, Disk, DiskCreateRequest, DiskCredentials, DiskDeletedResponse, - Error, ExecStreamEventType, GetTemplateOptions, Instance, ManagedProcess, - ManagedProcessConnectEvent, ManagedProcessConnectFrame, ManagedProcessConnectOptions, - ManagedProcessCreateRequest, ManagedProcessDeleteOptions, ManagedProcessSignal, - ManagedProcessWaitOptions, Network, NetworkCreateRequest, PaginationOptions, PtySize, - RequestOptions, Result, Snapshot, SnapshotDeletedResponse, Template, TemplateLogEvent, - TemplateLogsOptions, + ApiError, Checkpoint, CheckpointRestoreResponse, ComputerButtonRequest, ComputerClickRequest, + ComputerCreateScreenRequest, ComputerDragRequest, ComputerLaunchRequest, + ComputerListWindowsOptions, ComputerOpenRequest, ComputerPoint, ComputerScreen, + ComputerScreenConnection, ComputerScreenGeometry, ComputerScreenId, ComputerScreenOptions, + ComputerScreenshotOptions, ComputerScrollRequest, ComputerTypeRequest, ComputerWindow, + ComputerWindowGeometry, ComputerWindowMoveRequest, ComputerWindowResizeRequest, + CreateCheckpointRequest, Disk, DiskCreateRequest, DiskCredentials, DiskDeletedResponse, Error, + ExecStreamEventType, GetTemplateOptions, Instance, ManagedProcess, ManagedProcessConnectEvent, + ManagedProcessConnectFrame, ManagedProcessConnectOptions, ManagedProcessCreateRequest, + ManagedProcessDeleteOptions, ManagedProcessSignal, ManagedProcessWaitOptions, Network, + NetworkCreateRequest, PaginationOptions, PtySize, RequestOptions, Result, Snapshot, + SnapshotDeletedResponse, Template, TemplateLogEvent, TemplateLogsOptions, client::{encode, fetch_all}, transport::Transport, }; @@ -145,6 +145,72 @@ fn event(kind: &str) -> crate::CommandStreamEvent { pub struct FilesService { instance: Instance, } + +/// Checkpoint operations for one running sandbox. +#[derive(Clone)] +pub struct CheckpointsService { + instance: Instance, +} +impl CheckpointsService { + pub(crate) fn new(instance: Instance) -> Self { + Self { instance } + } + fn path(&self, checkpoint_id: Option<&str>, suffix: &str) -> String { + let mut path = self.instance.path("/checkpoints"); + if let Some(checkpoint_id) = checkpoint_id { + path.push('/'); + path.push_str(&encode(checkpoint_id)); + } + path.push_str(suffix); + path + } + /// Creates a checkpoint of one eligible directory. + pub async fn create(&self, request: CreateCheckpointRequest) -> Result { + self.instance + .transport + .send( + Method::POST, + &self.path(None, ""), + &[], + &request, + &RequestOptions::default(), + ) + .await + } + /// Lists checkpoints from oldest to newest. + pub async fn list(&self) -> Result> { + self.instance + .transport + .get(&self.path(None, ""), &[], &RequestOptions::default(), true) + .await + } + /// Restores a checkpoint to its original directory path. + pub async fn restore(&self, checkpoint_id: &str) -> Result { + self.instance + .transport + .empty( + Method::POST, + &self.path(Some(checkpoint_id), "/restore"), + &[], + &RequestOptions::default(), + ) + .await + } + /// Deletes one checkpoint record and its retained layer. + pub async fn delete(&self, checkpoint_id: &str) -> Result<()> { + let _: serde_json::Value = self + .instance + .transport + .empty( + Method::DELETE, + &self.path(Some(checkpoint_id), ""), + &[], + &RequestOptions::default(), + ) + .await?; + Ok(()) + } +} impl FilesService { pub(crate) fn new(instance: Instance) -> Self { Self { instance } @@ -1185,3 +1251,90 @@ fn add_opt(query: &mut Vec<(String, String)>, key: &str, value: Opt query.push((key.into(), value.to_string())); } } + +#[cfg(test)] +mod checkpoint_tests { + use super::*; + use crate::{Sandbox, SandboxStatus}; + use std::{ + io::{Read as _, Write as _}, + net::TcpListener, + sync::mpsc, + }; + use url::Url; + + #[tokio::test] + async fn checkpoint_lifecycle_uses_encoded_ids_and_bodyless_actions() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + let responses = [ + r#"{"status":"success","data":{"id":"cp_first/segment","path":"/root/project","name":"before-update","created_at":"2026-11-05T12:00:00Z"}}"#, + r#"{"status":"success","data":[{"id":"cp_first/segment","path":"/root/project","created_at":"2026-11-05T12:00:00Z"}]}"#, + r#"{"status":"success","data":{"checkpoint_id":"cp_first/segment","path":"/root/project"}}"#, + r#"{"status":"success","data":{}}"#, + ]; + std::thread::spawn(move || { + for body in responses { + let (mut connection, _) = listener.accept().unwrap(); + let mut request = [0_u8; 8192]; + let length = connection.read(&mut request).unwrap(); + sender + .send(String::from_utf8_lossy(&request[..length]).into_owned()) + .unwrap(); + write!( + connection, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + } + }); + let transport = Transport::new( + Url::parse(&format!("http://{address}")).unwrap(), + Some("owner".into()), + None, + None, + crate::RetryOptions::default(), + "test", + ) + .unwrap(); + let instance = Instance::new( + transport, + Sandbox { + id: "sb-1".into(), + status: SandboxStatus::from("running"), + ..Sandbox::default() + }, + ); + let checkpoints = instance.checkpoints(); + + let created = checkpoints + .create(CreateCheckpointRequest { + path: "/root/project".into(), + name: Some("before-update".into()), + }) + .await + .unwrap(); + assert_eq!(created.id, "cp_first/segment"); + assert_eq!(checkpoints.list().await.unwrap().len(), 1); + assert_eq!( + checkpoints.restore(&created.id).await.unwrap().path, + "/root/project" + ); + checkpoints.delete(&created.id).await.unwrap(); + + let requests: Vec = (0..4).map(|_| receiver.recv().unwrap()).collect(); + assert!(requests[0].starts_with("POST /v1/sandboxes/sb%2D1/checkpoints ")); + assert!(requests[0].contains(r#"{"path":"/root/project","name":"before-update"}"#)); + assert!(requests[1].starts_with("GET /v1/sandboxes/sb%2D1/checkpoints ")); + assert!( + requests[2] + .starts_with("POST /v1/sandboxes/sb%2D1/checkpoints/cp%5Ffirst%2Fsegment/restore ") + ); + assert!( + requests[3] + .starts_with("DELETE /v1/sandboxes/sb%2D1/checkpoints/cp%5Ffirst%2Fsegment ") + ); + } +}