diff --git a/CHANGELOG.md b/CHANGELOG.md
index f141fd7..24904de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+- Added reusable disk and full snapshot create, get, list, and delete operations,
+ plus sandbox restoration through `CreateSandboxRequest.SnapshotId`.
- Added sandbox labels to create and response models, AND-matched label
filtering in `ListSandboxesAsync()`, and label merging through
`SandboxInstance.SetLabelsAsync()`.
diff --git a/CreateOS.Sandbox.sln b/CreateOS.Sandbox.sln
index 843bac2..4e52b6a 100644
--- a/CreateOS.Sandbox.sln
+++ b/CreateOS.Sandbox.sln
@@ -24,6 +24,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ManagedProcess", "examples\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Network", "examples\Network\Network.csproj", "{A1000009-0000-4000-8000-000000000009}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Snapshots", "examples\Snapshots\Snapshots.csproj", "{A1000010-0000-4000-8000-000000000010}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreateOS.Sandbox.Tests", "tests\CreateOS.Sandbox.Tests\CreateOS.Sandbox.Tests.csproj", "{B2000001-0000-4000-8000-000000000001}"
EndProject
Global
@@ -76,6 +78,10 @@ Global
{A1000009-0000-4000-8000-000000000009}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000009-0000-4000-8000-000000000009}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1000009-0000-4000-8000-000000000009}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1000010-0000-4000-8000-000000000010}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1000010-0000-4000-8000-000000000010}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1000010-0000-4000-8000-000000000010}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1000010-0000-4000-8000-000000000010}.Release|Any CPU.Build.0 = Release|Any CPU
{B2000001-0000-4000-8000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2000001-0000-4000-8000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2000001-0000-4000-8000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
diff --git a/README.md b/README.md
index 650d3d7..545262c 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,27 @@ await sandbox.SetLabelsAsync(
List filters use AND semantics. `SetLabelsAsync()` merges the supplied keys
into the existing label map.
+## Capture and restore snapshots
+
+```csharp
+var snapshot = await sandbox.CreateSnapshotAsync(new CreateSnapshotRequest
+{
+ Type = SnapshotType.Disk,
+ Name = "configured-workspace"
+});
+snapshot = await client.Snapshots.GetAsync(snapshot.Id); // poll until Ready
+var restored = await client.CreateSandboxAsync(new CreateSandboxRequest
+{
+ SnapshotId = snapshot.Id
+});
+```
+
+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
An owner can create one delegated token for a sandbox. Plaintext is returned
@@ -474,6 +495,7 @@ Runnable examples live under [`examples/`](examples/):
- [HTTP execution server](examples/ExecutionServer/README.md)
- [Command streaming](examples/CommandStreaming/README.md)
- [Files and snapshots](examples/FilesAndSnapshots/README.md)
+- [Reusable snapshots](examples/Snapshots/README.md)
- [Ingress preview](examples/IngressPreview/README.md)
- [Private overlay network](examples/Network/README.md)
- [Custom template and Docker](examples/CustomTemplate/README.md)
diff --git a/examples/README.md b/examples/README.md
index e559965..e3472ea 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -26,6 +26,7 @@ the local project reference. No CreateOS API key belongs in the repository.
| [HelloWorld](HelloWorld/README.md) | Create a sandbox, run a command, destroy it |
| [CommandStreaming](CommandStreaming/README.md) | Read command output as it arrives |
| [FilesAndSnapshots](FilesAndSnapshots/README.md) | Upload/download files and snapshot a sandbox |
+| [Snapshots](Snapshots/README.md) | Capture and restore a reusable sandbox snapshot |
| [IngressPreview](IngressPreview/README.md) | Expose a service through an ingress preview URL |
| [Network](Network/README.md) | Connect sandboxes on a private network |
| [CustomTemplate](CustomTemplate/README.md) | Build and use a custom template |
diff --git a/examples/Snapshots/Program.cs b/examples/Snapshots/Program.cs
new file mode 100644
index 0000000..3408b02
--- /dev/null
+++ b/examples/Snapshots/Program.cs
@@ -0,0 +1,41 @@
+using CreateOS.Sandbox;
+
+using var client = new SandboxClient();
+var source = await client.CreateSandboxAsync(new CreateSandboxRequest
+{
+ Shape = "s-1vcpu-1gb",
+ RootFileSystem = "alpine:3.20"
+});
+Snapshot? snapshot = null;
+SandboxInstance? restored = null;
+
+try
+{
+ snapshot = await source.CreateSnapshotAsync(new CreateSnapshotRequest
+ {
+ Type = SnapshotType.Disk,
+ Name = "configured-workspace"
+ });
+
+ using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(5));
+ while (snapshot.Status == SnapshotStatus.Creating)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(1), timeout.Token);
+ snapshot = await client.Snapshots.GetAsync(snapshot.Id, timeout.Token);
+ }
+ if (snapshot.Status != SnapshotStatus.Ready)
+ throw new InvalidOperationException($"Snapshot {snapshot.Id} entered {snapshot.Status}.");
+
+ restored = await client.CreateSandboxAsync(new CreateSandboxRequest
+ {
+ SnapshotId = snapshot.Id
+ });
+ Console.WriteLine($"Restored sandbox {restored.Id} from snapshot {snapshot.Id}.");
+}
+finally
+{
+ if (restored is not null) await restored.DestroyAsync(CancellationToken.None);
+ await source.DestroyAsync(CancellationToken.None);
+ if (snapshot?.Status is SnapshotStatus.Ready or SnapshotStatus.Failed)
+ await client.Snapshots.DeleteAsync(snapshot.Id, CancellationToken.None);
+}
diff --git a/examples/Snapshots/README.md b/examples/Snapshots/README.md
new file mode 100644
index 0000000..48cc610
--- /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="..."
+dotnet run --project examples/Snapshots/Snapshots.csproj
+```
diff --git a/examples/Snapshots/Snapshots.csproj b/examples/Snapshots/Snapshots.csproj
new file mode 100644
index 0000000..6b512ec
--- /dev/null
+++ b/examples/Snapshots/Snapshots.csproj
@@ -0,0 +1 @@
+
diff --git a/src/CreateOS.Sandbox/AccountServices.cs b/src/CreateOS.Sandbox/AccountServices.cs
index 7c63068..dbd69ac 100644
--- a/src/CreateOS.Sandbox/AccountServices.cs
+++ b/src/CreateOS.Sandbox/AccountServices.cs
@@ -53,3 +53,13 @@ public sealed class DisksService
private async Task RequiredAsync(HttpMethod method, string path, object? body, CancellationToken token) => await _transport.SendAsync(method, path, body, cancellationToken: token).ConfigureAwait(false) ?? throw new JsonException($"{path} returned no data.");
private sealed record Deleted([property: System.Text.Json.Serialization.JsonPropertyName("deleted")] bool DeletedValue);
}
+
+/// Creates and manages reusable sandbox snapshots.
+public sealed class SnapshotsService
+{
+ private readonly Transport _transport; internal SnapshotsService(Transport transport) => _transport = transport;
+ public Task> ListAsync(PaginationOptions? options = null, CancellationToken token = default) => SandboxClient.FetchAllAsync(_transport, "/v1/snapshots", options?.Limit, options?.Offset ?? 0, "snapshots", null, null, token);
+ public Task GetAsync(string snapshotId, CancellationToken token = default) => RequiredAsync(HttpMethod.Get, $"/v1/snapshots/{SandboxClient.Segment(snapshotId)}", null, token);
+ public Task DeleteAsync(string snapshotId, CancellationToken token = default) => RequiredAsync(HttpMethod.Delete, $"/v1/snapshots/{SandboxClient.Segment(snapshotId)}", null, token);
+ private async Task RequiredAsync(HttpMethod method, string path, object? body, CancellationToken token) => await _transport.SendAsync(method, path, body, cancellationToken: token).ConfigureAwait(false) ?? throw new JsonException($"{path} returned no data.");
+}
diff --git a/src/CreateOS.Sandbox/Models.cs b/src/CreateOS.Sandbox/Models.cs
index cf155c3..69c66b5 100644
--- a/src/CreateOS.Sandbox/Models.cs
+++ b/src/CreateOS.Sandbox/Models.cs
@@ -4,6 +4,8 @@
namespace CreateOS.Sandbox;
public enum SandboxStatus { Creating, Running, Pausing, Paused, Resuming, Forking, Error, Destroying, Destroyed, Failed }
+public enum SnapshotType { Disk, Full }
+public enum SnapshotStatus { Creating, Ready, Failed, Deleting }
public enum HostStatus { Active, Draining, Dead }
public enum ExecStreamEventType { Stdout, Stderr, Exit, Error, Heartbeat }
public enum ManagedProcessKind { Process, Pty }
@@ -37,7 +39,8 @@ public enum ManagedProcessSignal
public sealed record NetworkEntry([property: JsonPropertyName("id")] string Id);
public sealed record CreateSandboxRequest
{
- [JsonPropertyName("shape")] public required string Shape { get; init; }
+ [JsonPropertyName("snapshot_id")] public string? SnapshotId { get; init; }
+ [JsonPropertyName("shape")] public string? Shape { get; init; }
[JsonPropertyName("rootfs")] public string? RootFileSystem { get; init; }
[JsonPropertyName("name")] public string? Name { get; init; }
[JsonPropertyName("labels")] public IReadOnlyDictionary? Labels { get; init; }
@@ -53,6 +56,11 @@ public sealed record CreateSandboxRequest
[JsonPropertyName("region")] public string? Region { get; init; }
[JsonPropertyName("auto_pause_after_seconds")] public int? AutoPauseAfterSeconds { get; init; }
}
+public sealed record CreateSnapshotRequest
+{
+ [JsonPropertyName("type")] public required SnapshotType Type { get; init; }
+ [JsonPropertyName("name")] public string? Name { get; init; }
+}
public sealed record ForkSandboxRequest
{
[JsonPropertyName("start_paused")] public bool? StartPaused { get; init; }
@@ -128,7 +136,24 @@ public sealed record SandboxData
[JsonPropertyName("last_resumed_at")] public DateTimeOffset? LastResumedAt { get; init; }
[JsonPropertyName("forked_from")] public string? ForkedFrom { get; init; }
[JsonPropertyName("auto_pause_after_seconds")] public int? AutoPauseAfterSeconds { get; init; }
+ [JsonPropertyName("snapshot_id")] public string? SnapshotId { get; init; }
+}
+public sealed record Snapshot
+{
+ public required string Id { get; init; }
+ [JsonPropertyName("sandbox_id")] public required string SandboxId { get; init; }
+ public string? Name { get; init; }
+ public SnapshotType Type { get; init; }
+ public SnapshotStatus Status { get; init; }
+ [JsonPropertyName("created_at")] public DateTimeOffset CreatedAt { get; init; }
+ [JsonPropertyName("ready_at")] public DateTimeOffset? ReadyAt { get; init; }
+ public string? Shape { get; init; }
+ [JsonPropertyName("vcpu")] public int VCpu { get; init; }
+ [JsonPropertyName("mem_mib")] public int MemoryMiB { get; init; }
+ [JsonPropertyName("disk_mib")] public long DiskMiB { get; init; }
+ [JsonPropertyName("size_bytes")] public long SizeBytes { get; init; }
}
+public sealed record SnapshotDeletedResponse(string Id, SnapshotStatus Status);
/// A plaintext delegated credential returned only on creation or rotation.
public sealed record SandboxAccessTokenCreateResponse(
string Token, bool Enabled,
diff --git a/src/CreateOS.Sandbox/SandboxClient.cs b/src/CreateOS.Sandbox/SandboxClient.cs
index 1b37794..5d1ffc0 100644
--- a/src/CreateOS.Sandbox/SandboxClient.cs
+++ b/src/CreateOS.Sandbox/SandboxClient.cs
@@ -17,6 +17,8 @@ public sealed class SandboxClient : IDisposable
public NetworksService Networks { get; }
/// Gets the persistent-disk service.
public DisksService Disks { get; }
+ /// Gets the reusable-snapshot service.
+ public SnapshotsService Snapshots { get; }
/// Initializes a client with explicit options or environment defaults.
/// Client configuration, or to use defaults.
@@ -26,6 +28,7 @@ public SandboxClient(SandboxClientOptions? options = null)
Templates = new TemplatesService(_transport);
Networks = new NetworksService(_transport);
Disks = new DisksService(_transport);
+ Snapshots = new SnapshotsService(_transport);
}
/// Gets the unauthenticated service health status.
diff --git a/src/CreateOS.Sandbox/SandboxInstance.cs b/src/CreateOS.Sandbox/SandboxInstance.cs
index a063edc..e7cb586 100644
--- a/src/CreateOS.Sandbox/SandboxInstance.cs
+++ b/src/CreateOS.Sandbox/SandboxInstance.cs
@@ -61,6 +61,9 @@ private async Task LifecycleAsync(HttpMethod method, string suffix,
public async Task RefreshAsync(CancellationToken cancellationToken = default) => Update(await RequiredAsync(HttpMethod.Get, Path(), null, cancellationToken).ConfigureAwait(false));
public async Task PauseAsync(CancellationToken cancellationToken = default) => _ = await LifecycleAsync(HttpMethod.Post, "/pause", null, cancellationToken).ConfigureAwait(false);
public async Task ResumeAsync(CancellationToken cancellationToken = default) => _ = await LifecycleAsync(HttpMethod.Post, "/resume", null, cancellationToken).ConfigureAwait(false);
+ /// Starts a reusable snapshot capture from this running sandbox.
+ public Task CreateSnapshotAsync(CreateSnapshotRequest request, CancellationToken cancellationToken = default) =>
+ RequiredAsync(HttpMethod.Post, Path("/snapshots"), request, cancellationToken);
public async Task ForkAsync(ForkSandboxRequest? request = null, CancellationToken cancellationToken = default) => new(_transport, await RequiredAsync(HttpMethod.Post, Path("/fork"), request ?? new ForkSandboxRequest(), cancellationToken).ConfigureAwait(false));
public async Task DestroyAsync(CancellationToken cancellationToken = default)
{