From 3d903f4ee0a4ab085e2d4b83bad01b1290d9f1e8 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 | 7 +++ README.md | 17 ++++++ src/main/java/sh/createos/CreateOsClient.java | 10 ++- src/main/java/sh/createos/Sandbox.java | 21 ++++++- .../createos/model/CreateSandboxRequest.java | 48 ++++++++++++++- .../createos/model/CreateSandboxResponse.java | 41 ++++++++++++- .../createos/model/ListSandboxesOptions.java | 12 +++- .../java/sh/createos/model/SandboxData.java | 61 ++++++++++++++++++- .../java/sh/createos/CreateOsClientTest.java | 47 +++++++++++++- 9 files changed, 254 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e5b49..2fff323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes will be documented here. This project follows Semantic Versioning and the Keep a Changelog structure. +## Unreleased + +### Added + +- Sandbox labels on create and response models, AND-matched label filtering in + `listSandboxes()`, and label merging through `Sandbox.setLabels()`. + ## 0.1.2 - 2026-09-21 ### Fixed diff --git a/README.md b/README.md index 8e5385f..5e7e39a 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,23 @@ The SDK targets Java 17 and uses the JDK HTTP client. Public wire models are immutable records, API failures remain inspectable through `ApiException`, and the build enforces the [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html). +## Organize sandboxes with labels + +```java +Sandbox sandbox = client.createSandbox( + CreateSandboxRequest.builder("s-1vcpu-1gb") + .labels(Map.of("team", "backend", "environment", "test")) + .build()); + +List matches = client.listSandboxes( + new ListSandboxesOptions(0, null, Map.of("team", "backend"))); + +sandbox.setLabels(Map.of("environment", "production")); +``` + +List filters use AND semantics. `setLabels()` 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/main/java/sh/createos/CreateOsClient.java b/src/main/java/sh/createos/CreateOsClient.java index 469a7f7..74e4172 100644 --- a/src/main/java/sh/createos/CreateOsClient.java +++ b/src/main/java/sh/createos/CreateOsClient.java @@ -158,7 +158,8 @@ public Sandbox createSandbox(CreateSandboxRequest request) { null, null, null, - request.autoPauseAfterSeconds()); + request.autoPauseAfterSeconds(), + created.labels()); return new Sandbox(transport, data); } @@ -178,6 +179,13 @@ public List listSandboxes(ListSandboxesOptions options) { if (options.status() != null) { query.put("status", options.status().wireValue()); } + if (!options.labels().isEmpty()) { + try { + query.put("labels", transport.objectMapper().writeValueAsString(options.labels())); + } catch (com.fasterxml.jackson.core.JsonProcessingException exception) { + throw new IllegalArgumentException("Failed to encode sandbox label filter", exception); + } + } return fetchAll("/v1/sandboxes", query, options.limit(), false, SandboxData.class).stream() .map(data -> new Sandbox(transport, data)) .toList(); diff --git a/src/main/java/sh/createos/Sandbox.java b/src/main/java/sh/createos/Sandbox.java index 977c9dd..19c50e6 100644 --- a/src/main/java/sh/createos/Sandbox.java +++ b/src/main/java/sh/createos/Sandbox.java @@ -63,6 +63,11 @@ public String ipAddress() { return data().ipAddress(); } + /** Returns the last observed user-defined labels. */ + public Map labels() { + return data().labels(); + } + /** Returns the latest immutable server projection. */ public SandboxData data() { lock.readLock().lock(); @@ -218,6 +223,19 @@ public void setIngress(boolean enabled) { SandboxData.class)); } + /** Merges labels into the sandbox's existing labels. */ + public void setLabels(Map labels) { + update( + transport.send( + "PATCH", + path(""), + Map.of(), + Map.of("labels", Map.copyOf(labels)), + RequestOptions.DEFAULT, + false, + SandboxData.class)); + } + /** Sets the idle auto-pause timeout, or disables it when {@code timeout} is null. */ public void setAutoPause(Duration timeout) { Map request; @@ -575,7 +593,8 @@ private static SandboxData copyWithStatus(SandboxData source, SandboxStatus stat source.pausedAt(), source.lastResumedAt(), source.forkedFrom(), - source.autoPauseAfterSeconds()); + source.autoPauseAfterSeconds(), + source.labels()); } private record DestroyedResponse(String id, SandboxStatus status) {} diff --git a/src/main/java/sh/createos/model/CreateSandboxRequest.java b/src/main/java/sh/createos/model/CreateSandboxRequest.java index ca4c22d..6ce252e 100644 --- a/src/main/java/sh/createos/model/CreateSandboxRequest.java +++ b/src/main/java/sh/createos/model/CreateSandboxRequest.java @@ -22,7 +22,8 @@ public record CreateSandboxRequest( @JsonProperty("ingress_enabled") Boolean ingressEnabled, List disks, String region, - @JsonProperty("auto_pause_after_seconds") Integer autoPauseAfterSeconds) { + @JsonProperty("auto_pause_after_seconds") Integer autoPauseAfterSeconds, + Map labels) { /** Validates required values and defensively copies collection values. */ public CreateSandboxRequest { shape = Objects.requireNonNull(shape, "shape"); @@ -36,6 +37,41 @@ public record CreateSandboxRequest( sshPublicKeys = sshPublicKeys == null ? List.of() : List.copyOf(sshPublicKeys); nodeSelector = nodeSelector == null ? Map.of() : Map.copyOf(nodeSelector); disks = disks == null ? List.of() : List.copyOf(disks); + labels = labels == null ? Map.of() : Map.copyOf(labels); + } + + /** Creates a request without labels, preserving the pre-labels constructor. */ + public CreateSandboxRequest( + String shape, + String rootFileSystem, + String name, + List networks, + Long diskMib, + List egressRules, + Map environmentVariables, + List sshPublicKeys, + String hostId, + Map nodeSelector, + Boolean ingressEnabled, + List disks, + String region, + Integer autoPauseAfterSeconds) { + this( + shape, + rootFileSystem, + name, + networks, + diskMib, + egressRules, + environmentVariables, + sshPublicKeys, + hostId, + nodeSelector, + ingressEnabled, + disks, + region, + autoPauseAfterSeconds, + Map.of()); } /** Returns a builder requiring the sandbox shape. */ @@ -59,6 +95,7 @@ public static final class Builder { private List disks = List.of(); private String region; private Integer autoPauseAfterSeconds; + private Map labels = Map.of(); private Builder(String shape) { this.shape = shape; @@ -76,6 +113,12 @@ public Builder name(String name) { return this; } + /** Sets user-defined metadata used to organize and filter sandboxes. */ + public Builder labels(Map labels) { + this.labels = labels; + return this; + } + /** Sets overlay networks attached during creation. */ public Builder networks(List networks) { this.networks = networks; @@ -158,7 +201,8 @@ public CreateSandboxRequest build() { ingressEnabled, disks, region, - autoPauseAfterSeconds); + autoPauseAfterSeconds, + labels); } } } diff --git a/src/main/java/sh/createos/model/CreateSandboxResponse.java b/src/main/java/sh/createos/model/CreateSandboxResponse.java index d8af54c..110a95f 100644 --- a/src/main/java/sh/createos/model/CreateSandboxResponse.java +++ b/src/main/java/sh/createos/model/CreateSandboxResponse.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; +import java.util.Map; /** Resolved placement and boot timing returned when a sandbox is created. */ @JsonIgnoreProperties(ignoreUnknown = true) @@ -19,4 +20,42 @@ public record CreateSandboxResponse( @JsonProperty("spawn_ms") double spawnMilliseconds, @JsonProperty("egress") List egressRules, @JsonProperty("bandwidth_quota_bytes") long bandwidthQuotaBytes, - @JsonProperty("ingress_url_template") String ingressUrlTemplate) {} + @JsonProperty("ingress_url_template") String ingressUrlTemplate, + Map labels) { + /** Normalizes missing labels and keeps the response immutable. */ + public CreateSandboxResponse { + labels = labels == null ? Map.of() : Map.copyOf(labels); + } + + /** Creates a response without labels, preserving the pre-labels constructor. */ + public CreateSandboxResponse( + String id, + SandboxStatus status, + String name, + String ipAddress, + String shape, + String rootFileSystem, + int virtualCpu, + int memoryMib, + long diskMib, + double spawnMilliseconds, + List egressRules, + long bandwidthQuotaBytes, + String ingressUrlTemplate) { + this( + id, + status, + name, + ipAddress, + shape, + rootFileSystem, + virtualCpu, + memoryMib, + diskMib, + spawnMilliseconds, + egressRules, + bandwidthQuotaBytes, + ingressUrlTemplate, + Map.of()); + } +} diff --git a/src/main/java/sh/createos/model/ListSandboxesOptions.java b/src/main/java/sh/createos/model/ListSandboxesOptions.java index 452eee1..526e858 100644 --- a/src/main/java/sh/createos/model/ListSandboxesOptions.java +++ b/src/main/java/sh/createos/model/ListSandboxesOptions.java @@ -1,14 +1,22 @@ package sh.createos.model; +import java.util.Map; + /** Filtering and result limits for sandbox listing. */ -public record ListSandboxesOptions(int limit, SandboxStatus status) { +public record ListSandboxesOptions(int limit, SandboxStatus status, Map labels) { /** Lists all sandboxes without a status filter. */ - public static final ListSandboxesOptions ALL = new ListSandboxesOptions(0, null); + public static final ListSandboxesOptions ALL = new ListSandboxesOptions(0, null, Map.of()); + + /** Creates options without a label filter. */ + public ListSandboxesOptions(int limit, SandboxStatus status) { + this(limit, status, Map.of()); + } /** Validates the result limit. */ public ListSandboxesOptions { if (limit < 0) { throw new IllegalArgumentException("limit must not be negative"); } + labels = labels == null ? Map.of() : Map.copyOf(labels); } } diff --git a/src/main/java/sh/createos/model/SandboxData.java b/src/main/java/sh/createos/model/SandboxData.java index d91cd52..71d6b08 100644 --- a/src/main/java/sh/createos/model/SandboxData.java +++ b/src/main/java/sh/createos/model/SandboxData.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.time.Instant; import java.util.List; +import java.util.Map; /** Complete server projection of a sandbox. */ @JsonIgnoreProperties(ignoreUnknown = true) @@ -32,12 +33,70 @@ public record SandboxData( @JsonProperty("paused_at") Instant pausedAt, @JsonProperty("last_resumed_at") Instant lastResumedAt, @JsonProperty("forked_from") String forkedFrom, - @JsonProperty("auto_pause_after_seconds") Integer autoPauseAfterSeconds) { + @JsonProperty("auto_pause_after_seconds") Integer autoPauseAfterSeconds, + Map labels) { /** Defensively copies collection values. */ public SandboxData { egressRules = egressRules == null ? List.of() : List.copyOf(egressRules); environmentVariables = environmentVariables == null ? List.of() : List.copyOf(environmentVariables); sshPublicKeys = sshPublicKeys == null ? List.of() : List.copyOf(sshPublicKeys); + labels = labels == null ? Map.of() : Map.copyOf(labels); + } + + /** Creates a projection without labels, preserving the pre-labels constructor. */ + public SandboxData( + String id, + SandboxStatus status, + String ipAddress, + int virtualCpu, + int memoryMib, + long diskMib, + Instant createdAt, + boolean ingressEnabled, + String ingressUrlTemplate, + String name, + Instant runningAt, + Instant destroyedAt, + double spawnMilliseconds, + String shape, + String rootFileSystem, + String region, + List egressRules, + List environmentVariables, + List sshPublicKeys, + String createdBy, + long bandwidthIngressBytes, + Instant pausedAt, + Instant lastResumedAt, + String forkedFrom, + Integer autoPauseAfterSeconds) { + this( + id, + status, + ipAddress, + virtualCpu, + memoryMib, + diskMib, + createdAt, + ingressEnabled, + ingressUrlTemplate, + name, + runningAt, + destroyedAt, + spawnMilliseconds, + shape, + rootFileSystem, + region, + egressRules, + environmentVariables, + sshPublicKeys, + createdBy, + bandwidthIngressBytes, + pausedAt, + lastResumedAt, + forkedFrom, + autoPauseAfterSeconds, + Map.of()); } } diff --git a/src/test/java/sh/createos/CreateOsClientTest.java b/src/test/java/sh/createos/CreateOsClientTest.java index 0574bb0..11fd53d 100644 --- a/src/test/java/sh/createos/CreateOsClientTest.java +++ b/src/test/java/sh/createos/CreateOsClientTest.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.net.InetSocketAddress; import java.net.URI; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @@ -88,7 +89,7 @@ void createsSandboxWithExpectedWireNames() { exchange, 200, """ - {"status":"success","data":{"id":"sb-1","status":"running","ip":"10.0.0.2","shape":"s-4vcpu-4gb","rootfs":"devbox:1","vcpu":4,"mem_mib":4096,"disk_mib":8192,"spawn_ms":42.5,"egress":[],"ingress_url_template":"https://.example.test"}} + {"status":"success","data":{"id":"sb-1","status":"running","labels":{"team":"backend"},"ip":"10.0.0.2","shape":"s-4vcpu-4gb","rootfs":"devbox:1","vcpu":4,"mem_mib":4096,"disk_mib":8192,"spawn_ms":42.5,"egress":[],"ingress_url_template":"https://.example.test"}} """); }); @@ -97,6 +98,7 @@ void createsSandboxWithExpectedWireNames() { .createSandbox( CreateSandboxRequest.builder("s-4vcpu-4gb") .rootFileSystem("devbox:1") + .labels(Map.of("team", "backend")) .ingressEnabled(true) .build()); @@ -104,6 +106,8 @@ void createsSandboxWithExpectedWireNames() { assertEquals("secret", apiKey.get()); assertTrue(requestBody.get().contains("\"rootfs\":\"devbox:1\"")); assertTrue(requestBody.get().contains("\"ingress_enabled\":true")); + assertTrue(requestBody.get().contains("\"labels\":{\"team\":\"backend\"}")); + assertEquals(Map.of("team", "backend"), sandbox.labels()); assertFalse(requestBody.get().contains("disk_mib")); } @@ -132,6 +136,10 @@ void walksServerPaginationUsingTotalMetadata() { server.createContext( "/v1/sandboxes", exchange -> { + String decodedQuery = + URLDecoder.decode(exchange.getRequestURI().getRawQuery(), StandardCharsets.UTF_8); + assertTrue(decodedQuery.contains("\"environment\":\"test\"")); + assertTrue(decodedQuery.contains("\"team\":\"backend\"")); int offset = exchange.getRequestURI().getQuery().contains("offset=1") ? 1 : 0; requests.incrementAndGet(); String status = offset == 0 ? "running" : "paused"; @@ -147,12 +155,47 @@ void walksServerPaginationUsingTotalMetadata() { + ",\"count\":1}}}"); }); - List sandboxes = client().listSandboxes(sh.createos.model.ListSandboxesOptions.ALL); + List sandboxes = + client() + .listSandboxes( + new sh.createos.model.ListSandboxesOptions( + 0, null, Map.of("team", "backend", "environment", "test"))); assertEquals(List.of("sb-1", "sb-2"), sandboxes.stream().map(Sandbox::id).toList()); assertEquals(2, requests.get()); } + @Test + void patchesLabelsAndUpdatesCachedState() { + AtomicInteger calls = new AtomicInteger(); + server.createContext( + "/v1/sandboxes/sb-1", + exchange -> { + if (calls.getAndIncrement() == 0) { + respond( + exchange, + 200, + "{\"status\":\"success\",\"data\":{\"id\":\"sb-1\",\"status\":\"running\"}}"); + return; + } + assertEquals("PATCH", exchange.getRequestMethod()); + String body = + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(body.contains("\"labels\":{\"team\":\"platform\"}")); + respond( + exchange, + 200, + "{\"status\":\"success\",\"data\":{\"id\":\"sb-1\"," + + "\"status\":\"running\",\"labels\":{\"environment\":\"test\"," + + "\"team\":\"platform\"}}}"); + }); + + Sandbox sandbox = client().getSandbox("sb-1"); + sandbox.setLabels(Map.of("team", "platform")); + + assertEquals(Map.of("environment", "test", "team", "platform"), sandbox.labels()); + } + @Test void exposesApiErrorMetadata() { server.createContext( From 5e4c82329fcb546171dc3b32f3c7c3c1e44f11c3 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Fri, 23 Oct 2026 16:02:43 +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 5e7e39a..d0d473a 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ the build enforces the [Google Java Style Guide](https://google.github.io/styleg ```java Sandbox sandbox = client.createSandbox( CreateSandboxRequest.builder("s-1vcpu-1gb") + .rootFileSystem("alpine:3.20") .labels(Map.of("team", "backend", "environment", "test")) .build());