diff --git a/CHANGELOG.md b/CHANGELOG.md index 24904de..77c20ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,14 @@ ## Unreleased +- Added checkpoint create, list, restore, and delete operations scoped + to a running sandbox through `SandboxInstance.Checkpoints`. - 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()`. +- Set the next NuGet version and default user agent to `0.1.4`. ## [0.1.3](https://github.com/NodeOps-app/createos-csharp-sdk/releases/tag/v0.1.3) — 2026-09-21 diff --git a/CreateOS.Sandbox.sln b/CreateOS.Sandbox.sln index 4e52b6a..c396916 100644 --- a/CreateOS.Sandbox.sln +++ b/CreateOS.Sandbox.sln @@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Network", "examples\Network EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Snapshots", "examples\Snapshots\Snapshots.csproj", "{A1000010-0000-4000-8000-000000000010}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Checkpoints", "examples\Checkpoints\Checkpoints.csproj", "{A1000011-0000-4000-8000-000000000011}" +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 @@ -82,6 +84,10 @@ Global {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 + {A1000011-0000-4000-8000-000000000011}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1000011-0000-4000-8000-000000000011}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1000011-0000-4000-8000-000000000011}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1000011-0000-4000-8000-000000000011}.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 545262c..94df844 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,28 @@ 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 + +Checkpoints keep local file history inside one running sandbox. Stop +writers before create or restore; processes are not paused automatically. + +```csharp +var checkpoint = await sandbox.Checkpoints.CreateAsync( + new CreateCheckpointRequest + { + Path = "/workspace/project", + Name = "before-update", + }); + +var checkpoints = await sandbox.Checkpoints.ListAsync(); +var restored = await sandbox.Checkpoints.RestoreAsync(checkpoint.Id); +await sandbox.Checkpoints.DeleteAsync(checkpoint.Id); +``` + +Restore always uses the checkpoint's original path. Checkpoints remain on the +sandbox disk and disappear with it. See the runnable +[checkpoint example](examples/Checkpoints/README.md). + ## Delegate access to one sandbox An owner can create one delegated token for a sandbox. Plaintext is returned diff --git a/examples/Checkpoints/Checkpoints.csproj b/examples/Checkpoints/Checkpoints.csproj new file mode 100644 index 0000000..6b512ec --- /dev/null +++ b/examples/Checkpoints/Checkpoints.csproj @@ -0,0 +1 @@ + diff --git a/examples/Checkpoints/Program.cs b/examples/Checkpoints/Program.cs new file mode 100644 index 0000000..39f41a5 --- /dev/null +++ b/examples/Checkpoints/Program.cs @@ -0,0 +1,44 @@ +using CreateOS.Sandbox; + +const string checkpointPath = "/workspace/checkpoint-demo"; + +using var client = new SandboxClient(); +var sandbox = await client.CreateSandboxAsync(new CreateSandboxRequest +{ + Shape = "s-1vcpu-1gb", + RootFileSystem = "devbox:1" +}); +Checkpoint? checkpoint = null; + +try +{ + await RunAsync(sandbox, $"mkdir -p {checkpointPath} && printf 'version one' > {checkpointPath}/value"); + checkpoint = await sandbox.Checkpoints.CreateAsync(new CreateCheckpointRequest + { + Path = checkpointPath, + Name = "before-update" + }); + await RunAsync(sandbox, $"printf 'version two' > {checkpointPath}/value"); + await sandbox.Checkpoints.RestoreAsync(checkpoint.Id); + var result = await RunAsync(sandbox, $"cat {checkpointPath}/value"); + Console.WriteLine($"Restored {checkpoint.Id}: {result.Result.StandardOutput}"); +} +finally +{ + try + { + if (checkpoint is not null) + await sandbox.Checkpoints.DeleteAsync(checkpoint.Id, CancellationToken.None); + } + finally + { + await sandbox.DestroyAsync(CancellationToken.None); + } +} + +static Task RunAsync(SandboxInstance sandbox, string script) => + sandbox.RunCommandAsync(new RunCommandRequest + { + Command = "sh", + Arguments = ["-c", script] + }); diff --git a/examples/Checkpoints/README.md b/examples/Checkpoints/README.md new file mode 100644 index 0000000..b461b17 --- /dev/null +++ b/examples/Checkpoints/README.md @@ -0,0 +1,12 @@ +# Checkpoints + +Capture a directory, modify it, restore its earlier contents, and remove the +checkpoint. + +```sh +export CREATEOS_API_KEY="your-api-key" +dotnet run --project examples/Checkpoints/Checkpoints.csproj +``` + +The example creates live resources and cleans them up when it finishes. Stop +all writers before creating or restoring a checkpoint. diff --git a/examples/README.md b/examples/README.md index e3472ea..ed84b3d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -27,6 +27,7 @@ the local project reference. No CreateOS API key belongs in the repository. | [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 | +| [Checkpoints](Checkpoints/README.md) | Capture and restore local directory history | | [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/src/CreateOS.Sandbox/CheckpointsService.cs b/src/CreateOS.Sandbox/CheckpointsService.cs new file mode 100644 index 0000000..638f78d --- /dev/null +++ b/src/CreateOS.Sandbox/CheckpointsService.cs @@ -0,0 +1,39 @@ +using System.Text.Json; +using CreateOS.Sandbox.Internal; + +namespace CreateOS.Sandbox; + +/// Manages local checkpoints inside one running sandbox. +public sealed class CheckpointsService +{ + private readonly SandboxInstance _instance; + private readonly Transport _transport; + + internal CheckpointsService(SandboxInstance instance, Transport transport) + { + _instance = instance; + _transport = transport; + } + + /// Captures an eligible absolute directory. + public async Task CreateAsync(CreateCheckpointRequest request, CancellationToken cancellationToken = default) => + await _transport.SendAsync(HttpMethod.Post, Path(), request, cancellationToken: cancellationToken).ConfigureAwait(false) + ?? throw new JsonException("Checkpoint create response contained no data."); + + /// Lists checkpoints oldest first. + public async Task> ListAsync(CancellationToken cancellationToken = default) => + await _transport.SendAsync(HttpMethod.Get, Path(), cancellationToken: cancellationToken).ConfigureAwait(false) + ?? []; + + /// Restores a checkpoint at its original directory path. + public async Task RestoreAsync(string checkpointId, CancellationToken cancellationToken = default) => + await _transport.SendAsync(HttpMethod.Post, Path(checkpointId, "/restore"), cancellationToken: cancellationToken).ConfigureAwait(false) + ?? throw new JsonException("Checkpoint restore response contained no data."); + + /// Deletes a checkpoint record while retaining referenced layers. + public async Task DeleteAsync(string checkpointId, CancellationToken cancellationToken = default) => + _ = await _transport.SendAsync(HttpMethod.Delete, Path(checkpointId), cancellationToken: cancellationToken).ConfigureAwait(false); + + private string Path(string? checkpointId = null, string suffix = "") => + _instance.Path("/checkpoints") + (checkpointId is null ? "" : "/" + SandboxClient.Segment(checkpointId)) + suffix; +} diff --git a/src/CreateOS.Sandbox/CreateOS.Sandbox.csproj b/src/CreateOS.Sandbox/CreateOS.Sandbox.csproj index fee73cd..dc9fe3c 100644 --- a/src/CreateOS.Sandbox/CreateOS.Sandbox.csproj +++ b/src/CreateOS.Sandbox/CreateOS.Sandbox.csproj @@ -6,7 +6,7 @@ true $(NoWarn);1591 CreateOS.Sandbox - 0.1.3 + 0.1.4 CreateOS Official .NET SDK for CreateOS Sandbox. README.md diff --git a/src/CreateOS.Sandbox/Models.cs b/src/CreateOS.Sandbox/Models.cs index 69c66b5..e05d711 100644 --- a/src/CreateOS.Sandbox/Models.cs +++ b/src/CreateOS.Sandbox/Models.cs @@ -61,6 +61,11 @@ public sealed record CreateSnapshotRequest [JsonPropertyName("type")] public required SnapshotType Type { get; init; } [JsonPropertyName("name")] public string? Name { get; init; } } +public sealed record CreateCheckpointRequest +{ + [JsonPropertyName("path")] public required string Path { get; init; } + [JsonPropertyName("name")] public string? Name { get; init; } +} public sealed record ForkSandboxRequest { [JsonPropertyName("start_paused")] public bool? StartPaused { get; init; } @@ -138,6 +143,19 @@ public sealed record SandboxData [JsonPropertyName("auto_pause_after_seconds")] public int? AutoPauseAfterSeconds { get; init; } [JsonPropertyName("snapshot_id")] public string? SnapshotId { get; init; } } +public sealed record Checkpoint +{ + [JsonPropertyName("id")] public required string Id { get; init; } + [JsonPropertyName("path")] public required string Path { get; init; } + [JsonPropertyName("name")] public string? Name { get; init; } + [JsonPropertyName("parent_id")] public string? ParentId { get; init; } + [JsonPropertyName("created_at")] public DateTimeOffset CreatedAt { get; init; } +} +public sealed record CheckpointRestoreResponse +{ + [JsonPropertyName("checkpoint_id")] public required string CheckpointId { get; init; } + [JsonPropertyName("path")] public required string Path { get; init; } +} public sealed record Snapshot { public required string Id { get; init; } diff --git a/src/CreateOS.Sandbox/Options.cs b/src/CreateOS.Sandbox/Options.cs index 9dc0fff..7283b5a 100644 --- a/src/CreateOS.Sandbox/Options.cs +++ b/src/CreateOS.Sandbox/Options.cs @@ -16,7 +16,7 @@ public sealed class SandboxClientOptions /// Gets or sets the maximum delay between retry attempts. public TimeSpan RetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30); /// Gets or sets the value sent in the User-Agent header. - public string UserAgent { get; set; } = "createos-csharp-sdk/0.1.3"; + public string UserAgent { get; set; } = "createos-csharp-sdk/0.1.4"; /// Gets or sets a custom handler for proxy, TLS, or connection settings. Redirects are always disabled. public HttpClientHandler? HttpClientHandler { get; set; } } diff --git a/src/CreateOS.Sandbox/SandboxInstance.cs b/src/CreateOS.Sandbox/SandboxInstance.cs index e7cb586..2af3aba 100644 --- a/src/CreateOS.Sandbox/SandboxInstance.cs +++ b/src/CreateOS.Sandbox/SandboxInstance.cs @@ -16,6 +16,7 @@ public sealed class SandboxInstance public FilesService Files { get; } public ProcessesService Processes { get; } public ComputerService Computer { get; } + public CheckpointsService Checkpoints { get; } public string Id { get { lock (_gate) return _data.Id; } } public string? Name { get { lock (_gate) return _data.Name; } } public SandboxStatus Status { get { lock (_gate) return _data.Status; } } @@ -26,7 +27,7 @@ public sealed class SandboxInstance internal SandboxInstance(Transport transport, SandboxData data) { _transport = transport; _data = data; - Files = new FilesService(this, transport); Processes = new ProcessesService(this, transport); Computer = new ComputerService(this, transport); + Files = new FilesService(this, transport); Processes = new ProcessesService(this, transport); Computer = new ComputerService(this, transport); Checkpoints = new CheckpointsService(this, transport); } internal string Path(string suffix = "") => $"/v1/sandboxes/{SandboxClient.Segment(Id)}{suffix}"; private void Update(SandboxData data) { lock (_gate) _data = data; } diff --git a/tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs b/tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs index 8dcec07..9a5adcd 100644 --- a/tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs +++ b/tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs @@ -79,6 +79,36 @@ public async Task SetLabelsPatchesAndUpdatesCachedState() Assert.Equal("platform", instance.Labels["team"]); } + [Fact] + public async Task CheckpointLifecycleEncodesIds() + { + var responses = new Queue([ + "{\"status\":\"success\",\"data\":{\"id\":\"cp_first\",\"path\":\"/workspace/app\",\"created_at\":\"2026-11-05T12:00:00Z\"}}", + "{\"status\":\"success\",\"data\":[{\"id\":\"cp_first\",\"path\":\"/workspace/app\",\"created_at\":\"2026-11-05T12:00:00Z\"}]}", + "{\"status\":\"success\",\"data\":{\"checkpoint_id\":\"cp_first/segment\",\"path\":\"/workspace/app\"}}", + "{\"status\":\"success\",\"data\":{}}", + ]); + var requests = new List<(string Method, string Path, string Body)>(); + using var transport = CreateTransport(new DelegateHandler(request => + { + requests.Add((request.Method.Method, request.RequestUri!.AbsolutePath, request.Content?.ReadAsStringAsync().GetAwaiter().GetResult() ?? "")); + return Json(request, responses.Dequeue()); + })); + var instance = Instance(transport, ingress: false); + + var checkpoint = await instance.Checkpoints.CreateAsync(new CreateCheckpointRequest { Path = "/workspace/app", Name = "before-update" }); + var checkpoints = await instance.Checkpoints.ListAsync(); + var restored = await instance.Checkpoints.RestoreAsync("cp_first/segment"); + await instance.Checkpoints.DeleteAsync("cp_first/segment"); + + Assert.Equal("cp_first", checkpoint.Id); + Assert.Single(checkpoints); + Assert.Equal("/workspace/app", restored.Path); + Assert.Contains("\"path\":\"/workspace/app\"", requests[0].Body, StringComparison.Ordinal); + Assert.Equal("/v1/sandboxes/sb-1/checkpoints/cp_first%2Fsegment/restore", requests[2].Path); + Assert.Equal("/v1/sandboxes/sb-1/checkpoints/cp_first%2Fsegment", requests[3].Path); + } + [Fact] public async Task ShellThrowsForNonzeroExit() {