From 4c230937bbcf4ec2d61f3207d2e12c2dcd3154fb Mon Sep 17 00:00:00 2001 From: Bhautik Date: Fri, 23 Oct 2026 15:40:40 +0530 Subject: [PATCH 1/2] feat: add sandbox labels support --- CHANGELOG.md | 6 +++++ README.md | 25 +++++++++++++++++++ src/CreateOS.Sandbox/Models.cs | 2 ++ src/CreateOS.Sandbox/Options.cs | 2 ++ src/CreateOS.Sandbox/SandboxClient.cs | 6 ++++- src/CreateOS.Sandbox/SandboxInstance.cs | 4 +++ .../ModelSerializationTests.cs | 16 ++++++++++++ .../SandboxInstanceTests.cs | 18 +++++++++++++ 8 files changed, 78 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4f0247..f141fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Added sandbox labels to create and response models, AND-matched label + filtering in `ListSandboxesAsync()`, and label merging through + `SandboxInstance.SetLabelsAsync()`. + ## [0.1.3](https://github.com/NodeOps-app/createos-csharp-sdk/releases/tag/v0.1.3) — 2026-09-21 - Redact plaintext sandbox access tokens from response string output to prevent accidental disclosure in logs. diff --git a/README.md b/README.md index 339177b..5082a37 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,31 @@ var client = new SandboxClient(new SandboxClientOptions `CREATEOS_SANDBOX_BASE_URL` optionally configures the endpoint when `BaseUri` is not supplied. +## Organize sandboxes with labels + +```csharp +var sandbox = await client.CreateSandboxAsync(new CreateSandboxRequest +{ + Shape = "s-1vcpu-1gb", + Labels = new Dictionary + { + ["team"] = "backend", + ["environment"] = "test", + }, +}); + +var matches = await client.ListSandboxesAsync(new ListSandboxesOptions +{ + Labels = new Dictionary { ["team"] = "backend" }, +}); + +await sandbox.SetLabelsAsync( + new Dictionary { ["environment"] = "production" }); +``` + +List filters use AND semantics. `SetLabelsAsync()` merges the supplied keys +into the existing label map. + ## Delegate access to one sandbox An owner can create one delegated token for a sandbox. Plaintext is returned diff --git a/src/CreateOS.Sandbox/Models.cs b/src/CreateOS.Sandbox/Models.cs index be8f680..cf155c3 100644 --- a/src/CreateOS.Sandbox/Models.cs +++ b/src/CreateOS.Sandbox/Models.cs @@ -40,6 +40,7 @@ public sealed record CreateSandboxRequest [JsonPropertyName("shape")] public required 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; } [JsonPropertyName("networks")] public IReadOnlyList? Networks { get; init; } [JsonPropertyName("disk_mib")] public long? DiskMiB { get; init; } [JsonPropertyName("egress")] public IReadOnlyList? EgressRules { get; init; } @@ -111,6 +112,7 @@ public sealed record SandboxData [JsonPropertyName("ingress_enabled")] public bool IngressEnabled { get; init; } [JsonPropertyName("ingress_url_template")] public string? IngressUrlTemplate { get; init; } public string? Name { get; init; } + public IReadOnlyDictionary? Labels { get; init; } [JsonPropertyName("running_at")] public DateTimeOffset? RunningAt { get; init; } [JsonPropertyName("destroyed_at")] public DateTimeOffset? DestroyedAt { get; init; } [JsonPropertyName("spawn_ms")] public double? SpawnMilliseconds { get; init; } diff --git a/src/CreateOS.Sandbox/Options.cs b/src/CreateOS.Sandbox/Options.cs index b2498e2..9dc0fff 100644 --- a/src/CreateOS.Sandbox/Options.cs +++ b/src/CreateOS.Sandbox/Options.cs @@ -50,6 +50,8 @@ public sealed class ListSandboxesOptions : RequestOptions { public int? Limit { get; init; } public SandboxStatus? Status { get; init; } + /// Gets labels that must all match on returned sandboxes. + public IReadOnlyDictionary? Labels { get; init; } } public sealed class PaginationOptions { diff --git a/src/CreateOS.Sandbox/SandboxClient.cs b/src/CreateOS.Sandbox/SandboxClient.cs index 1ae8ccb..1b37794 100644 --- a/src/CreateOS.Sandbox/SandboxClient.cs +++ b/src/CreateOS.Sandbox/SandboxClient.cs @@ -72,7 +72,11 @@ public async Task GetSandboxByIpAsync(string ipAddress, Cancell /// Lists sandboxes, transparently walking server-side pages. public async Task> ListSandboxesAsync(ListSandboxesOptions? options = null, CancellationToken cancellationToken = default) { - var query = options?.Status is { } status ? new Dictionary { ["status"] = Wire(status) } : null; + Dictionary? query = null; + if (options?.Status is { } status) + (query ??= [])["status"] = Wire(status); + if (options?.Labels is { Count: > 0 } labels) + (query ??= [])["labels"] = JsonSerializer.Serialize(labels, Transport.Json); var data = await FetchAllAsync(_transport, "/v1/sandboxes", options?.Limit, 0, null, query, options, cancellationToken).ConfigureAwait(false); return data.Select(x => new SandboxInstance(_transport, x)).ToArray(); } diff --git a/src/CreateOS.Sandbox/SandboxInstance.cs b/src/CreateOS.Sandbox/SandboxInstance.cs index 9ab228f..a063edc 100644 --- a/src/CreateOS.Sandbox/SandboxInstance.cs +++ b/src/CreateOS.Sandbox/SandboxInstance.cs @@ -20,6 +20,7 @@ public sealed class SandboxInstance public string? Name { get { lock (_gate) return _data.Name; } } public SandboxStatus Status { get { lock (_gate) return _data.Status; } } public string? IpAddress { get { lock (_gate) return _data.IpAddress; } } + public IReadOnlyDictionary Labels { get { lock (_gate) return _data.Labels is null ? new Dictionary() : new Dictionary(_data.Labels); } } public SandboxData Data { get { lock (_gate) return _data; } } internal SandboxInstance(Transport transport, SandboxData data) @@ -72,6 +73,9 @@ public async Task ResizeAsync(long diskMiB, CancellationToken cancellation lock (_gate) _data = _data with { DiskMiB = result.DiskMiB }; return result.DiskMiB; } public async Task SetIngressAsync(bool enabled, CancellationToken cancellationToken = default) => _ = await LifecycleAsync(HttpMethod.Patch, "", new { ingress_enabled = enabled }, cancellationToken).ConfigureAwait(false); + /// Merges labels into the sandbox's existing labels. + public async Task SetLabelsAsync(IReadOnlyDictionary labels, CancellationToken cancellationToken = default) => + _ = await LifecycleAsync(HttpMethod.Patch, "", new { labels }, cancellationToken).ConfigureAwait(false); public async Task SetAutoPauseAsync(TimeSpan? timeout, CancellationToken cancellationToken = default) { if (timeout is { } duration && (duration < TimeSpan.FromMinutes(1) || duration > TimeSpan.FromHours(24) || duration.Ticks % TimeSpan.TicksPerSecond != 0)) diff --git a/tests/CreateOS.Sandbox.Tests/ModelSerializationTests.cs b/tests/CreateOS.Sandbox.Tests/ModelSerializationTests.cs index 62911f7..ec3090d 100644 --- a/tests/CreateOS.Sandbox.Tests/ModelSerializationTests.cs +++ b/tests/CreateOS.Sandbox.Tests/ModelSerializationTests.cs @@ -27,6 +27,22 @@ public void OmitsNullOptionalRequestFields() Assert.Equal("{\"shape\":\"s-1vcpu-1gb\"}", json); } + [Fact] + public void SerializesAndDeserializesSandboxLabels() + { + var json = JsonSerializer.Serialize(new CreateSandboxRequest + { + Shape = "s-1vcpu-1gb", + Labels = new Dictionary { ["team"] = "backend" }, + }, Transport.Json); + var sandbox = JsonSerializer.Deserialize( + "{\"id\":\"sb-1\",\"status\":\"running\",\"labels\":{\"team\":\"backend\"}}", + Transport.Json); + + Assert.Contains("\"labels\":{\"team\":\"backend\"}", json, StringComparison.Ordinal); + Assert.Equal("backend", sandbox!.Labels!["team"]); + } + [Fact] public void DeserializesKnownWireEnumsAndRejectsUnknown() { diff --git a/tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs b/tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs index 359a01f..8dcec07 100644 --- a/tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs +++ b/tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs @@ -61,6 +61,24 @@ public async Task PauseUpdatesCachedStatus() Assert.Equal(SandboxStatus.Paused, instance.Status); } + [Fact] + public async Task SetLabelsPatchesAndUpdatesCachedState() + { + using var transport = CreateTransport(new DelegateHandler(request => + { + Assert.Equal(HttpMethod.Patch, request.Method); + var body = request.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + Assert.Contains("\"labels\":{\"team\":\"platform\"}", body, StringComparison.Ordinal); + return Json(request, "{\"status\":\"success\",\"data\":{\"id\":\"sb-1\",\"status\":\"running\",\"labels\":{\"environment\":\"test\",\"team\":\"platform\"}}}"); + })); + var instance = Instance(transport, ingress: false); + + await instance.SetLabelsAsync(new Dictionary { ["team"] = "platform" }); + + Assert.Equal("test", instance.Labels["environment"]); + Assert.Equal("platform", instance.Labels["team"]); + } + [Fact] public async Task ShellThrowsForNonzeroExit() { From 41fa39983eb786b22fe0e207dde1e03bccca81f6 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Fri, 23 Oct 2026 16:02:44 +0530 Subject: [PATCH 2/2] docs: make labels example runnable --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5082a37..650d3d7 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ is not supplied. var sandbox = await client.CreateSandboxAsync(new CreateSandboxRequest { Shape = "s-1vcpu-1gb", + RootFileSystem = "alpine:3.20", Labels = new Dictionary { ["team"] = "backend",