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) {