diff --git a/CHANGELOG.md b/CHANGELOG.md index 35bc50f..558b90c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,17 @@ Versioning and the Keep a Changelog structure. ### Added +- Checkpoint create, list, restore, and delete operations scoped to + a running sandbox through `Sandbox.checkpoints()`. - Reusable disk and full snapshot create, get, list, and delete operations, plus sandbox restoration through `CreateSandboxRequest.fromSnapshot()`. - Sandbox labels on create and response models, AND-matched label filtering in `listSandboxes()`, and label merging through `Sandbox.setLabels()`. +### Changed + +- Set the next Maven version and default user agent to `0.1.3`. + ## 0.1.2 - 2026-09-21 ### Fixed diff --git a/README.md b/README.md index 49bbd68..b74929a 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,26 @@ capture requires a running source sandbox with no attached external disks. Each account may retain up to five snapshots across both types. See the compile-checked [snapshot example](examples/src/main/java/sh/createos/examples/snapshots/Snapshots.java). +## Checkpoint a directory + +Checkpoints keep local file history inside one running sandbox. Stop +writers before create or restore; processes are not paused automatically. + +```java +Checkpoint checkpoint = sandbox.checkpoints().create( + new CreateCheckpointRequest( + "/workspace/project", "before-update")); + +List checkpoints = sandbox.checkpoints().list(); +CheckpointRestoreResponse restored = + sandbox.checkpoints().restore(checkpoint.id()); +sandbox.checkpoints().delete(checkpoint.id()); +``` + +Restore always uses the checkpoint's original path. Checkpoints remain on the +sandbox disk and disappear with it. See the +[runnable example](examples/src/main/java/sh/createos/examples/checkpoints/README.md). + ## Delegate access to one sandbox An owner can create one delegated token for a sandbox. Plaintext is returned @@ -401,6 +421,7 @@ validated by `ExamplesDocumentationTest`: - [Command streaming](examples/src/main/java/sh/createos/examples/commandstreaming/README.md) - [Files and snapshots](examples/src/main/java/sh/createos/examples/filesandsnapshots/README.md) - [Reusable snapshots](examples/src/main/java/sh/createos/examples/snapshots/README.md) +- [Checkpoints](examples/src/main/java/sh/createos/examples/checkpoints/README.md) - [Ingress preview](examples/src/main/java/sh/createos/examples/ingresspreview/README.md) - [Private overlay network](examples/src/main/java/sh/createos/examples/network/README.md) - [Custom template](examples/src/main/java/sh/createos/examples/customtemplate/README.md) diff --git a/examples/README.md b/examples/README.md index e0b0c7a..9f427c1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -34,6 +34,7 @@ Replace the final class with one of these: | `managed-process` | `sh.createos.examples.managedprocess.ManagedProcessExample` | | `network` | `sh.createos.examples.network.NetworkExample` | | `snapshots` | `sh.createos.examples.snapshots.Snapshots` | +| `checkpoints` | `sh.createos.examples.checkpoints.Checkpoints` | Examples create billable resources. Their `finally` blocks make a best effort to destroy sandboxes and delete temporary account resources. diff --git a/examples/src/main/java/sh/createos/examples/checkpoints/Checkpoints.java b/examples/src/main/java/sh/createos/examples/checkpoints/Checkpoints.java new file mode 100644 index 0000000..813184e --- /dev/null +++ b/examples/src/main/java/sh/createos/examples/checkpoints/Checkpoints.java @@ -0,0 +1,40 @@ +package sh.createos.examples.checkpoints; + +import sh.createos.CreateOsClient; +import sh.createos.Sandbox; +import sh.createos.model.Checkpoint; +import sh.createos.model.CreateCheckpointRequest; +import sh.createos.model.CreateSandboxRequest; + +/** Captures and restores local directory history inside one sandbox. */ +public final class Checkpoints { + private static final String PATH = "/workspace/checkpoint-demo"; + + private Checkpoints() {} + + /** Runs the example. */ + public static void main(String[] arguments) { + CreateOsClient client = + CreateOsClient.builder().apiKey(System.getenv("CREATEOS_API_KEY")).build(); + Sandbox sandbox = + client.createSandbox( + CreateSandboxRequest.builder("s-1vcpu-1gb").rootFileSystem("devbox:1").build()); + Checkpoint checkpoint = null; + try { + sandbox.shell("mkdir -p " + PATH + " && printf 'version one' > " + PATH + "/value"); + checkpoint = sandbox.checkpoints().create(new CreateCheckpointRequest(PATH, "before-update")); + sandbox.shell("printf 'version two' > " + PATH + "/value"); + sandbox.checkpoints().restore(checkpoint.id()); + String contents = sandbox.shell("cat " + PATH + "/value").result().standardOutput(); + System.out.printf("restored %s: %s%n", checkpoint.id(), contents); + } finally { + try { + if (checkpoint != null) { + sandbox.checkpoints().delete(checkpoint.id()); + } + } finally { + sandbox.destroy(); + } + } + } +} diff --git a/examples/src/main/java/sh/createos/examples/checkpoints/README.md b/examples/src/main/java/sh/createos/examples/checkpoints/README.md new file mode 100644 index 0000000..d968e38 --- /dev/null +++ b/examples/src/main/java/sh/createos/examples/checkpoints/README.md @@ -0,0 +1,15 @@ +# Checkpoints + +Capture a directory, modify it, restore its earlier contents, and remove the +checkpoint. + +```sh +export CREATEOS_API_KEY="..." +mvn verify +mvn dependency:build-classpath -Dmdep.outputFile=/tmp/createos-java-classpath +java -cp "target/classes:target/example-classes:$(cat /tmp/createos-java-classpath)" \ + sh.createos.examples.checkpoints.Checkpoints +``` + +The example creates live resources and cleans them up when it finishes. Stop +all writers before creating or restoring a checkpoint. diff --git a/pom.xml b/pom.xml index e60bb81..8dbe1a8 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ sh.createos createos-java-sdk - 0.1.2 + 0.1.3 CreateOS Java SDK Java SDK for CreateOS Sandbox https://github.com/NodeOps-app/createos-java-sdk diff --git a/src/main/java/sh/createos/CheckpointsService.java b/src/main/java/sh/createos/CheckpointsService.java new file mode 100644 index 0000000..49d2529 --- /dev/null +++ b/src/main/java/sh/createos/CheckpointsService.java @@ -0,0 +1,74 @@ +package sh.createos; + +import java.util.List; +import java.util.Map; +import sh.createos.internal.HttpTransport; +import sh.createos.model.Checkpoint; +import sh.createos.model.CheckpointRestoreResponse; +import sh.createos.model.CreateCheckpointRequest; + +/** Local checkpoint operations for one running sandbox. */ +public final class CheckpointsService { + private final Sandbox sandbox; + + CheckpointsService(Sandbox sandbox) { + this.sandbox = sandbox; + } + + /** Captures an eligible absolute directory. */ + public Checkpoint create(CreateCheckpointRequest request) { + return sandbox + .transport() + .send( + "POST", + path("", ""), + Map.of(), + request, + RequestOptions.DEFAULT, + false, + Checkpoint.class); + } + + /** Returns checkpoints oldest first. */ + public List list() { + return sandbox + .transport() + .sendList("GET", path("", ""), Map.of(), RequestOptions.DEFAULT, false, Checkpoint.class); + } + + /** Restores a checkpoint at its original directory path. */ + public CheckpointRestoreResponse restore(String checkpointId) { + return sandbox + .transport() + .send( + "POST", + path(checkpointId, "/restore"), + Map.of(), + null, + RequestOptions.DEFAULT, + false, + CheckpointRestoreResponse.class); + } + + /** Deletes a checkpoint record while retaining referenced layers. */ + public void delete(String checkpointId) { + sandbox + .transport() + .send( + "DELETE", + path(checkpointId, ""), + Map.of(), + null, + RequestOptions.DEFAULT, + false, + Void.class); + } + + private String path(String checkpointId, String suffix) { + String path = sandbox.path("/checkpoints"); + if (!checkpointId.isEmpty()) { + path += "/" + HttpTransport.encodePathSegment(checkpointId); + } + return path + suffix; + } +} diff --git a/src/main/java/sh/createos/CreateOsClient.java b/src/main/java/sh/createos/CreateOsClient.java index ae51a43..94c67e2 100644 --- a/src/main/java/sh/createos/CreateOsClient.java +++ b/src/main/java/sh/createos/CreateOsClient.java @@ -270,7 +270,7 @@ public static final class Builder { private URI baseUri; private HttpClient httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(); - private String userAgent = "createos-java-sdk/0.1.2"; + private String userAgent = "createos-java-sdk/0.1.3"; private Duration timeout = Duration.ofSeconds(60); private RetryPolicy retryPolicy = new RetryPolicy(2, Duration.ofMillis(500), Duration.ofSeconds(30)); diff --git a/src/main/java/sh/createos/Sandbox.java b/src/main/java/sh/createos/Sandbox.java index 2616621..b2b6081 100644 --- a/src/main/java/sh/createos/Sandbox.java +++ b/src/main/java/sh/createos/Sandbox.java @@ -35,6 +35,7 @@ public final class Sandbox { private final FilesService files; private final ProcessesService processes; private final ComputerService computer; + private final CheckpointsService checkpoints; private SandboxData data; Sandbox(HttpTransport transport, SandboxData data) { @@ -43,6 +44,7 @@ public final class Sandbox { this.files = new FilesService(this); this.processes = new ProcessesService(this); this.computer = new ComputerService(this); + this.checkpoints = new CheckpointsService(this); } /** Returns the sandbox identifier. */ @@ -95,6 +97,11 @@ public ComputerService computer() { return computer; } + /** Returns local checkpoint operations for this sandbox. */ + public CheckpointsService checkpoints() { + return checkpoints; + } + /** Returns a separate sandbox handle using a delegated token for runtime operations. */ public Sandbox withAccessToken(String token) { if (token == null || token.isBlank()) { diff --git a/src/main/java/sh/createos/model/Checkpoint.java b/src/main/java/sh/createos/model/Checkpoint.java new file mode 100644 index 0000000..f624c4e --- /dev/null +++ b/src/main/java/sh/createos/model/Checkpoint.java @@ -0,0 +1,14 @@ +package sh.createos.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.Instant; + +/** Local file history retained inside one sandbox. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Checkpoint( + String id, + String path, + String name, + @JsonProperty("parent_id") String parentId, + @JsonProperty("created_at") Instant createdAt) {} diff --git a/src/main/java/sh/createos/model/CheckpointRestoreResponse.java b/src/main/java/sh/createos/model/CheckpointRestoreResponse.java new file mode 100644 index 0000000..9ca3ba4 --- /dev/null +++ b/src/main/java/sh/createos/model/CheckpointRestoreResponse.java @@ -0,0 +1,7 @@ +package sh.createos.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Identifies the restored checkpoint and its original directory path. */ +public record CheckpointRestoreResponse( + @JsonProperty("checkpoint_id") String checkpointId, String path) {} diff --git a/src/main/java/sh/createos/model/CreateCheckpointRequest.java b/src/main/java/sh/createos/model/CreateCheckpointRequest.java new file mode 100644 index 0000000..92c81ca --- /dev/null +++ b/src/main/java/sh/createos/model/CreateCheckpointRequest.java @@ -0,0 +1,13 @@ +package sh.createos.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.Objects; + +/** Selects a directory whose local file history should be captured. */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CreateCheckpointRequest(String path, String name) { + /** Validates the required directory path. */ + public CreateCheckpointRequest { + Objects.requireNonNull(path, "path"); + } +} diff --git a/src/test/java/sh/createos/CreateOsClientTest.java b/src/test/java/sh/createos/CreateOsClientTest.java index 11fd53d..168ef29 100644 --- a/src/test/java/sh/createos/CreateOsClientTest.java +++ b/src/test/java/sh/createos/CreateOsClientTest.java @@ -19,6 +19,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import sh.createos.model.CreateCheckpointRequest; import sh.createos.model.CreateSandboxRequest; import sh.createos.model.HealthResponse; import sh.createos.model.Shape; @@ -196,6 +197,72 @@ void patchesLabelsAndUpdatesCachedState() { assertEquals(Map.of("environment", "test", "team", "platform"), sandbox.labels()); } + @Test + void managesCheckpointLifecycleAndEncodesIds() { + AtomicInteger step = new AtomicInteger(); + server.createContext( + "/v1/sandboxes/sb-1", + exchange -> { + int index = step.getAndIncrement(); + String path = exchange.getRequestURI().getRawPath(); + if (index == 0) { + respond( + exchange, + 200, + "{\"status\":\"success\",\"data\":{\"id\":\"sb-1\",\"status\":\"running\"}}"); + } else if (index == 1) { + assertEquals("POST", exchange.getRequestMethod()); + assertEquals("/v1/sandboxes/sb-1/checkpoints", path); + String body = + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(body.contains("\"path\":\"/workspace/app\"")); + assertTrue(body.contains("\"name\":\"before-update\"")); + respond( + exchange, + 200, + "{\"status\":\"success\",\"data\":{\"id\":\"cp_first\"," + + "\"path\":\"/workspace/app\"," + + "\"created_at\":\"2026-11-05T12:00:00Z\"}}"); + } else if (index == 2) { + assertEquals("GET", exchange.getRequestMethod()); + respond( + exchange, + 200, + "{\"status\":\"success\",\"data\":[{\"id\":\"cp_first\"," + + "\"path\":\"/workspace/app\"," + + "\"created_at\":\"2026-11-05T12:00:00Z\"}]}"); + } else if (index == 3) { + assertEquals("POST", exchange.getRequestMethod()); + assertEquals("/v1/sandboxes/sb-1/checkpoints/cp_first%2Fsegment/restore", path); + assertEquals(0, exchange.getRequestBody().readAllBytes().length); + respond( + exchange, + 200, + "{\"status\":\"success\",\"data\":{" + + "\"checkpoint_id\":\"cp_first/segment\"," + + "\"path\":\"/workspace/app\"}}"); + } else { + assertEquals("DELETE", exchange.getRequestMethod()); + assertEquals("/v1/sandboxes/sb-1/checkpoints/cp_first%2Fsegment", path); + respond(exchange, 200, "{\"status\":\"success\",\"data\":{}}"); + } + }); + + Sandbox sandbox = client().getSandbox("sb-1"); + var checkpoint = + sandbox + .checkpoints() + .create(new CreateCheckpointRequest("/workspace/app", "before-update")); + var checkpoints = sandbox.checkpoints().list(); + var restored = sandbox.checkpoints().restore("cp_first/segment"); + assertEquals("/workspace/app", restored.path()); + sandbox.checkpoints().delete("cp_first/segment"); + + assertEquals("cp_first", checkpoint.id()); + assertEquals(1, checkpoints.size()); + assertEquals(5, step.get()); + } + @Test void exposesApiErrorMetadata() { server.createContext(