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 @@ -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()`.
Expand Down
6 changes: 6 additions & 0 deletions CreateOS.Sandbox.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
41 changes: 41 additions & 0 deletions examples/Snapshots/Program.cs
Original file line number Diff line number Diff line change
@@ -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);
}
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="..."
dotnet run --project examples/Snapshots/Snapshots.csproj
```
1 change: 1 addition & 0 deletions examples/Snapshots/Snapshots.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk" />
10 changes: 10 additions & 0 deletions src/CreateOS.Sandbox/AccountServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,13 @@ public sealed class DisksService
private async Task<T> RequiredAsync<T>(HttpMethod method, string path, object? body, CancellationToken token) => await _transport.SendAsync<T>(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);
}

/// <summary>Creates and manages reusable sandbox snapshots.</summary>
public sealed class SnapshotsService
{
private readonly Transport _transport; internal SnapshotsService(Transport transport) => _transport = transport;
public Task<IReadOnlyList<Snapshot>> ListAsync(PaginationOptions? options = null, CancellationToken token = default) => SandboxClient.FetchAllAsync<Snapshot>(_transport, "/v1/snapshots", options?.Limit, options?.Offset ?? 0, "snapshots", null, null, token);
public Task<Snapshot> GetAsync(string snapshotId, CancellationToken token = default) => RequiredAsync<Snapshot>(HttpMethod.Get, $"/v1/snapshots/{SandboxClient.Segment(snapshotId)}", null, token);
public Task<SnapshotDeletedResponse> DeleteAsync(string snapshotId, CancellationToken token = default) => RequiredAsync<SnapshotDeletedResponse>(HttpMethod.Delete, $"/v1/snapshots/{SandboxClient.Segment(snapshotId)}", null, token);
private async Task<T> RequiredAsync<T>(HttpMethod method, string path, object? body, CancellationToken token) => await _transport.SendAsync<T>(method, path, body, cancellationToken: token).ConfigureAwait(false) ?? throw new JsonException($"{path} returned no data.");
}
27 changes: 26 additions & 1 deletion src/CreateOS.Sandbox/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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<string, string>? Labels { get; init; }
Expand All @@ -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; }
Expand Down Expand Up @@ -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);
/// <summary>A plaintext delegated credential returned only on creation or rotation.</summary>
public sealed record SandboxAccessTokenCreateResponse(
string Token, bool Enabled,
Expand Down
3 changes: 3 additions & 0 deletions src/CreateOS.Sandbox/SandboxClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public sealed class SandboxClient : IDisposable
public NetworksService Networks { get; }
/// <summary>Gets the persistent-disk service.</summary>
public DisksService Disks { get; }
/// <summary>Gets the reusable-snapshot service.</summary>
public SnapshotsService Snapshots { get; }

/// <summary>Initializes a client with explicit options or environment defaults.</summary>
/// <param name="options">Client configuration, or <see langword="null"/> to use defaults.</param>
Expand All @@ -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);
}

/// <summary>Gets the unauthenticated service health status.</summary>
Expand Down
3 changes: 3 additions & 0 deletions src/CreateOS.Sandbox/SandboxInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ private async Task<SandboxData> LifecycleAsync(HttpMethod method, string suffix,
public async Task RefreshAsync(CancellationToken cancellationToken = default) => Update(await RequiredAsync<SandboxData>(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);
/// <summary>Starts a reusable snapshot capture from this running sandbox.</summary>
public Task<Snapshot> CreateSnapshotAsync(CreateSnapshotRequest request, CancellationToken cancellationToken = default) =>
RequiredAsync<Snapshot>(HttpMethod.Post, Path("/snapshots"), request, cancellationToken);
public async Task<SandboxInstance> ForkAsync(ForkSandboxRequest? request = null, CancellationToken cancellationToken = default) => new(_transport, await RequiredAsync<SandboxData>(HttpMethod.Post, Path("/fork"), request ?? new ForkSandboxRequest(), cancellationToken).ConfigureAwait(false));
public async Task DestroyAsync(CancellationToken cancellationToken = default)
{
Expand Down
Loading