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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,32 @@ 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",
RootFileSystem = "alpine:3.20",
Labels = new Dictionary<string, string>
{
["team"] = "backend",
["environment"] = "test",
},
});

var matches = await client.ListSandboxesAsync(new ListSandboxesOptions
{
Labels = new Dictionary<string, string> { ["team"] = "backend" },
});

await sandbox.SetLabelsAsync(
new Dictionary<string, string> { ["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
Expand Down
2 changes: 2 additions & 0 deletions src/CreateOS.Sandbox/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>? Labels { get; init; }
[JsonPropertyName("networks")] public IReadOnlyList<NetworkEntry>? Networks { get; init; }
[JsonPropertyName("disk_mib")] public long? DiskMiB { get; init; }
[JsonPropertyName("egress")] public IReadOnlyList<string>? EgressRules { get; init; }
Expand Down Expand Up @@ -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<string, string>? 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; }
Expand Down
2 changes: 2 additions & 0 deletions src/CreateOS.Sandbox/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ public sealed class ListSandboxesOptions : RequestOptions
{
public int? Limit { get; init; }
public SandboxStatus? Status { get; init; }
/// <summary>Gets labels that must all match on returned sandboxes.</summary>
public IReadOnlyDictionary<string, string>? Labels { get; init; }
}
public sealed class PaginationOptions
{
Expand Down
6 changes: 5 additions & 1 deletion src/CreateOS.Sandbox/SandboxClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ public async Task<SandboxInstance> GetSandboxByIpAsync(string ipAddress, Cancell
/// <summary>Lists sandboxes, transparently walking server-side pages.</summary>
public async Task<IReadOnlyList<SandboxInstance>> ListSandboxesAsync(ListSandboxesOptions? options = null, CancellationToken cancellationToken = default)
{
var query = options?.Status is { } status ? new Dictionary<string, string?> { ["status"] = Wire(status) } : null;
Dictionary<string, string?>? 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<SandboxData>(_transport, "/v1/sandboxes", options?.Limit, 0, null, query, options, cancellationToken).ConfigureAwait(false);
return data.Select(x => new SandboxInstance(_transport, x)).ToArray();
}
Expand Down
4 changes: 4 additions & 0 deletions src/CreateOS.Sandbox/SandboxInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> Labels { get { lock (_gate) return _data.Labels is null ? new Dictionary<string, string>() : new Dictionary<string, string>(_data.Labels); } }
public SandboxData Data { get { lock (_gate) return _data; } }

internal SandboxInstance(Transport transport, SandboxData data)
Expand Down Expand Up @@ -72,6 +73,9 @@ public async Task<long> 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);
/// <summary>Merges labels into the sandbox's existing labels.</summary>
public async Task SetLabelsAsync(IReadOnlyDictionary<string, string> 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))
Expand Down
16 changes: 16 additions & 0 deletions tests/CreateOS.Sandbox.Tests/ModelSerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> { ["team"] = "backend" },
}, Transport.Json);
var sandbox = JsonSerializer.Deserialize<SandboxData>(
"{\"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()
{
Expand Down
18 changes: 18 additions & 0 deletions tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> { ["team"] = "platform" });

Assert.Equal("test", instance.Labels["environment"]);
Assert.Equal("platform", instance.Labels["team"]);
}

[Fact]
public async Task ShellThrowsForNonzeroExit()
{
Expand Down
Loading