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
4 changes: 3 additions & 1 deletion 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

- 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
Expand All @@ -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.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions examples/checkpoints/README.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions examples/checkpoints/main.rs
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 10 additions & 6 deletions src/instance.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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.
///
Expand Down
6 changes: 3 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
12 changes: 12 additions & 0 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,11 @@ CreateSnapshotRequest {
#[serde(rename = "type")] kind: SnapshotType,
#[serde(default, skip_serializing_if = "Option::is_none")] name: Option<String>
});
model!(/// Checkpoint creation request.
CreateCheckpointRequest {
path: String,
#[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 @@ -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<String>, parent_id: Option<String>,
created_at: DateTime<Utc>
});
model!(/// Result of restoring a checkpoint.
CheckpointRestoreResponse { checkpoint_id: String, path: String });

/// Plaintext delegated token returned only when created or rotated.
#[derive(Clone, Deserialize)]
Expand Down
177 changes: 165 additions & 12 deletions src/services.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand Down Expand Up @@ -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<Checkpoint> {
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<Vec<Checkpoint>> {
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<CheckpointRestoreResponse> {
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 }
Expand Down Expand Up @@ -1185,3 +1251,90 @@ fn add_opt<T: ToString>(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<String> = (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 ")
);
}
}
Loading