diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fff323..35bc50f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ Versioning and the Keep a Changelog structure. ### Added +- 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()`. diff --git a/README.md b/README.md index d0d473a..49bbd68 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,22 @@ sandbox.setLabels(Map.of("environment", "production")); List filters use AND semantics. `setLabels()` merges the supplied keys into the existing label map. +## Capture and restore snapshots + +```java +Snapshot snapshot = + sandbox.createSnapshot(new CreateSnapshotRequest(SnapshotType.DISK, "configured-workspace")); +snapshot = client.snapshots().get(snapshot.id()); // poll until READY +Sandbox restored = + client.createSandbox(CreateSandboxRequest.fromSnapshot(snapshot.id()).build()); +``` + +Disk snapshots capture the filesystem. Full snapshots also retain memory and +must be restored with their captured CPU, memory, and disk sizes. Snapshot +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). + ## Delegate access to one sandbox An owner can create one delegated token for a sandbox. Plaintext is returned @@ -384,6 +400,7 @@ validated by `ExamplesDocumentationTest`: - [HTTP execution server](examples/src/main/java/sh/createos/examples/executionserver/README.md) - [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) - [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 59bdeae..e0b0c7a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -33,6 +33,7 @@ Replace the final class with one of these: | `ingress-preview` | `sh.createos.examples.ingresspreview.IngressPreview` | | `managed-process` | `sh.createos.examples.managedprocess.ManagedProcessExample` | | `network` | `sh.createos.examples.network.NetworkExample` | +| `snapshots` | `sh.createos.examples.snapshots.Snapshots` | 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/snapshots/README.md b/examples/src/main/java/sh/createos/examples/snapshots/README.md new file mode 100644 index 0000000..5ec001f --- /dev/null +++ b/examples/src/main/java/sh/createos/examples/snapshots/README.md @@ -0,0 +1,12 @@ +# Reusable snapshots + +Capture a reusable disk snapshot, wait for it to become ready, and restore a +new sandbox from it. + +```bash +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.snapshots.Snapshots +``` diff --git a/examples/src/main/java/sh/createos/examples/snapshots/Snapshots.java b/examples/src/main/java/sh/createos/examples/snapshots/Snapshots.java new file mode 100644 index 0000000..bbd3526 --- /dev/null +++ b/examples/src/main/java/sh/createos/examples/snapshots/Snapshots.java @@ -0,0 +1,59 @@ +package sh.createos.examples.snapshots; + +import java.time.Duration; +import java.time.Instant; +import sh.createos.CreateOsClient; +import sh.createos.Sandbox; +import sh.createos.model.CreateSandboxRequest; +import sh.createos.model.CreateSnapshotRequest; +import sh.createos.model.Snapshot; +import sh.createos.model.SnapshotStatus; +import sh.createos.model.SnapshotType; + +/** Captures a reusable disk snapshot and restores a second sandbox from it. */ +public final class Snapshots { + private Snapshots() {} + + /** Runs the example. */ + public static void main(String[] arguments) throws InterruptedException { + CreateOsClient client = + CreateOsClient.builder().apiKey(System.getenv("CREATEOS_API_KEY")).build(); + Sandbox source = + client.createSandbox( + CreateSandboxRequest.builder("s-1vcpu-1gb").rootFileSystem("alpine:3.20").build()); + Snapshot snapshot = null; + Sandbox restored = null; + try { + snapshot = + source.createSnapshot( + new CreateSnapshotRequest(SnapshotType.DISK, "configured-workspace")); + snapshot = waitUntilReady(client, snapshot.id(), Duration.ofMinutes(5)); + restored = client.createSandbox(CreateSandboxRequest.fromSnapshot(snapshot.id()).build()); + System.out.printf("restored sandbox %s from snapshot %s%n", restored.id(), snapshot.id()); + } finally { + if (restored != null) { + restored.destroy(); + } + source.destroy(); + if (snapshot != null && snapshot.status() != SnapshotStatus.CREATING) { + client.snapshots().delete(snapshot.id()); + } + } + } + + private static Snapshot waitUntilReady(CreateOsClient client, String id, Duration timeout) + throws InterruptedException { + Instant deadline = Instant.now().plus(timeout); + while (Instant.now().isBefore(deadline)) { + Snapshot snapshot = client.snapshots().get(id); + if (snapshot.status() == SnapshotStatus.READY) { + return snapshot; + } + if (snapshot.status() == SnapshotStatus.FAILED) { + throw new IllegalStateException("snapshot capture failed: " + id); + } + Thread.sleep(1000); + } + throw new IllegalStateException("timed out waiting for snapshot: " + id); + } +} diff --git a/src/main/java/sh/createos/CreateOsClient.java b/src/main/java/sh/createos/CreateOsClient.java index 74e4172..ae51a43 100644 --- a/src/main/java/sh/createos/CreateOsClient.java +++ b/src/main/java/sh/createos/CreateOsClient.java @@ -31,6 +31,7 @@ public final class CreateOsClient { private final TemplatesService templates; private final NetworksService networks; private final DisksService disks; + private final SnapshotsService snapshots; private CreateOsClient(Builder builder) { this.baseUri = builder.baseUri; @@ -45,6 +46,7 @@ private CreateOsClient(Builder builder) { this.templates = new TemplatesService(this); this.networks = new NetworksService(this); this.disks = new DisksService(this); + this.snapshots = new SnapshotsService(this); } /** @@ -75,6 +77,11 @@ public DisksService disks() { return disks; } + /** Returns account-level reusable snapshot operations. */ + public SnapshotsService snapshots() { + return snapshots; + } + /** Returns the unauthenticated control-plane liveness state. */ public HealthResponse health() { return transport.send( @@ -159,7 +166,8 @@ public Sandbox createSandbox(CreateSandboxRequest request) { null, null, request.autoPauseAfterSeconds(), - created.labels()); + created.labels(), + created.snapshotId()); return new Sandbox(transport, data); } diff --git a/src/main/java/sh/createos/Sandbox.java b/src/main/java/sh/createos/Sandbox.java index 19c50e6..2616621 100644 --- a/src/main/java/sh/createos/Sandbox.java +++ b/src/main/java/sh/createos/Sandbox.java @@ -12,6 +12,7 @@ import sh.createos.internal.RawResponse; import sh.createos.model.Bandwidth; import sh.createos.model.CommandStreamFrame; +import sh.createos.model.CreateSnapshotRequest; import sh.createos.model.DiskAttachment; import sh.createos.model.Egress; import sh.createos.model.ForkSandboxRequest; @@ -24,6 +25,7 @@ import sh.createos.model.SandboxData; import sh.createos.model.SandboxDisk; import sh.createos.model.SandboxStatus; +import sh.createos.model.Snapshot; /** Stateful handle to one CreateOS sandbox. */ public final class Sandbox { @@ -166,6 +168,18 @@ public void resume() { lifecycle("/resume"); } + /** Starts a reusable snapshot capture from this running sandbox. */ + public Snapshot createSnapshot(CreateSnapshotRequest request) { + return transport.send( + "POST", + path("/snapshots"), + Map.of(), + request, + RequestOptions.DEFAULT, + false, + Snapshot.class); + } + /** Creates an independent sandbox fork. */ public Sandbox fork(ForkSandboxRequest request) { SandboxData forked = @@ -594,7 +608,8 @@ private static SandboxData copyWithStatus(SandboxData source, SandboxStatus stat source.lastResumedAt(), source.forkedFrom(), source.autoPauseAfterSeconds(), - source.labels()); + source.labels(), + source.snapshotId()); } private record DestroyedResponse(String id, SandboxStatus status) {} diff --git a/src/main/java/sh/createos/SnapshotsService.java b/src/main/java/sh/createos/SnapshotsService.java new file mode 100644 index 0000000..5a6e849 --- /dev/null +++ b/src/main/java/sh/createos/SnapshotsService.java @@ -0,0 +1,53 @@ +package sh.createos; + +import java.util.List; +import java.util.Map; +import sh.createos.internal.HttpTransport; +import sh.createos.model.PaginationOptions; +import sh.createos.model.Snapshot; +import sh.createos.model.SnapshotDeletedResponse; + +/** Account-level reusable sandbox snapshot operations. */ +public final class SnapshotsService { + private final CreateOsClient client; + + SnapshotsService(CreateOsClient client) { + this.client = client; + } + + /** Lists reusable snapshots owned by the caller. */ + public List list(PaginationOptions options) { + return client.fetchAll( + "/v1/snapshots", + options.offset() == 0 ? Map.of() : Map.of("offset", Integer.toString(options.offset())), + options.limit(), + false, + Snapshot.class); + } + + /** Returns an owned reusable snapshot by identifier. */ + public Snapshot get(String snapshotId) { + return client + .transport() + .send( + "GET", path(snapshotId), Map.of(), null, RequestOptions.DEFAULT, false, Snapshot.class); + } + + /** Removes an owned ready or failed snapshot from API listings. */ + public SnapshotDeletedResponse delete(String snapshotId) { + return client + .transport() + .send( + "DELETE", + path(snapshotId), + Map.of(), + null, + RequestOptions.DEFAULT, + false, + SnapshotDeletedResponse.class); + } + + private static String path(String snapshotId) { + return "/v1/snapshots/" + HttpTransport.encodePathSegment(snapshotId); + } +} diff --git a/src/main/java/sh/createos/model/CreateSandboxRequest.java b/src/main/java/sh/createos/model/CreateSandboxRequest.java index 6ce252e..40d3efa 100644 --- a/src/main/java/sh/createos/model/CreateSandboxRequest.java +++ b/src/main/java/sh/createos/model/CreateSandboxRequest.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; import java.util.Map; -import java.util.Objects; /** Request used to create a sandbox. */ @JsonInclude(JsonInclude.Include.NON_EMPTY) @@ -23,12 +22,12 @@ public record CreateSandboxRequest( List disks, String region, @JsonProperty("auto_pause_after_seconds") Integer autoPauseAfterSeconds, - Map labels) { + Map labels, + @JsonProperty("snapshot_id") String snapshotId) { /** Validates required values and defensively copies collection values. */ public CreateSandboxRequest { - shape = Objects.requireNonNull(shape, "shape"); - if (shape.isBlank()) { - throw new IllegalArgumentException("shape must not be blank"); + if ((shape == null || shape.isBlank()) && (snapshotId == null || snapshotId.isBlank())) { + throw new IllegalArgumentException("shape or snapshotId must not be blank"); } networks = networks == null ? List.of() : List.copyOf(networks); egressRules = egressRules == null ? List.of() : List.copyOf(egressRules); @@ -71,7 +70,44 @@ public CreateSandboxRequest( disks, region, autoPauseAfterSeconds, - Map.of()); + Map.of(), + null); + } + + /** Creates a request with labels, preserving the pre-snapshots 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, + Map labels) { + this( + shape, + rootFileSystem, + name, + networks, + diskMib, + egressRules, + environmentVariables, + sshPublicKeys, + hostId, + nodeSelector, + ingressEnabled, + disks, + region, + autoPauseAfterSeconds, + labels, + null); } /** Returns a builder requiring the sandbox shape. */ @@ -79,6 +115,11 @@ public static Builder builder(String shape) { return new Builder(shape); } + /** Returns a builder that restores an owned ready reusable snapshot. */ + public static Builder fromSnapshot(String snapshotId) { + return new Builder(null).snapshotId(snapshotId); + } + /** Builder for optional sandbox creation settings. */ public static final class Builder { private final String shape; @@ -96,6 +137,7 @@ public static final class Builder { private String region; private Integer autoPauseAfterSeconds; private Map labels = Map.of(); + private String snapshotId; private Builder(String shape) { this.shape = shape; @@ -119,6 +161,12 @@ public Builder labels(Map labels) { return this; } + /** Restores an owned ready reusable snapshot instead of a root filesystem. */ + public Builder snapshotId(String snapshotId) { + this.snapshotId = snapshotId; + return this; + } + /** Sets overlay networks attached during creation. */ public Builder networks(List networks) { this.networks = networks; @@ -202,7 +250,8 @@ public CreateSandboxRequest build() { disks, region, autoPauseAfterSeconds, - labels); + labels, + snapshotId); } } } diff --git a/src/main/java/sh/createos/model/CreateSandboxResponse.java b/src/main/java/sh/createos/model/CreateSandboxResponse.java index 110a95f..051434b 100644 --- a/src/main/java/sh/createos/model/CreateSandboxResponse.java +++ b/src/main/java/sh/createos/model/CreateSandboxResponse.java @@ -21,7 +21,8 @@ public record CreateSandboxResponse( @JsonProperty("egress") List egressRules, @JsonProperty("bandwidth_quota_bytes") long bandwidthQuotaBytes, @JsonProperty("ingress_url_template") String ingressUrlTemplate, - Map labels) { + Map labels, + @JsonProperty("snapshot_id") String snapshotId) { /** Normalizes missing labels and keeps the response immutable. */ public CreateSandboxResponse { labels = labels == null ? Map.of() : Map.copyOf(labels); @@ -56,6 +57,41 @@ public CreateSandboxResponse( egressRules, bandwidthQuotaBytes, ingressUrlTemplate, - Map.of()); + Map.of(), + null); + } + + /** Creates a response with labels, preserving the pre-snapshots 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, + Map labels) { + this( + id, + status, + name, + ipAddress, + shape, + rootFileSystem, + virtualCpu, + memoryMib, + diskMib, + spawnMilliseconds, + egressRules, + bandwidthQuotaBytes, + ingressUrlTemplate, + labels, + null); } } diff --git a/src/main/java/sh/createos/model/CreateSnapshotRequest.java b/src/main/java/sh/createos/model/CreateSnapshotRequest.java new file mode 100644 index 0000000..751cd5b --- /dev/null +++ b/src/main/java/sh/createos/model/CreateSnapshotRequest.java @@ -0,0 +1,13 @@ +package sh.createos.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.Objects; + +/** Request used to capture a reusable snapshot. */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record CreateSnapshotRequest(SnapshotType type, String name) { + /** Validates the required snapshot type. */ + public CreateSnapshotRequest { + Objects.requireNonNull(type, "type"); + } +} diff --git a/src/main/java/sh/createos/model/SandboxData.java b/src/main/java/sh/createos/model/SandboxData.java index 71d6b08..5e9734a 100644 --- a/src/main/java/sh/createos/model/SandboxData.java +++ b/src/main/java/sh/createos/model/SandboxData.java @@ -34,7 +34,8 @@ public record SandboxData( @JsonProperty("last_resumed_at") Instant lastResumedAt, @JsonProperty("forked_from") String forkedFrom, @JsonProperty("auto_pause_after_seconds") Integer autoPauseAfterSeconds, - Map labels) { + Map labels, + @JsonProperty("snapshot_id") String snapshotId) { /** Defensively copies collection values. */ public SandboxData { egressRules = egressRules == null ? List.of() : List.copyOf(egressRules); @@ -97,6 +98,65 @@ public SandboxData( lastResumedAt, forkedFrom, autoPauseAfterSeconds, - Map.of()); + Map.of(), + null); + } + + /** Creates a projection with labels, preserving the pre-snapshots 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, + Map labels) { + 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, + labels, + null); } } diff --git a/src/main/java/sh/createos/model/Snapshot.java b/src/main/java/sh/createos/model/Snapshot.java new file mode 100644 index 0000000..c86b8f4 --- /dev/null +++ b/src/main/java/sh/createos/model/Snapshot.java @@ -0,0 +1,21 @@ +package sh.createos.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.time.Instant; + +/** Owned reusable sandbox snapshot. */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Snapshot( + String id, + @JsonProperty("sandbox_id") String sandboxId, + String name, + SnapshotType type, + SnapshotStatus status, + @JsonProperty("created_at") Instant createdAt, + @JsonProperty("ready_at") Instant readyAt, + String shape, + @JsonProperty("vcpu") int virtualCpu, + @JsonProperty("mem_mib") int memoryMib, + @JsonProperty("disk_mib") long diskMib, + @JsonProperty("size_bytes") long sizeBytes) {} diff --git a/src/main/java/sh/createos/model/SnapshotDeletedResponse.java b/src/main/java/sh/createos/model/SnapshotDeletedResponse.java new file mode 100644 index 0000000..02665b4 --- /dev/null +++ b/src/main/java/sh/createos/model/SnapshotDeletedResponse.java @@ -0,0 +1,4 @@ +package sh.createos.model; + +/** Accepted reusable snapshot deletion. */ +public record SnapshotDeletedResponse(String id, SnapshotStatus status) {} diff --git a/src/main/java/sh/createos/model/SnapshotStatus.java b/src/main/java/sh/createos/model/SnapshotStatus.java new file mode 100644 index 0000000..1fa47d8 --- /dev/null +++ b/src/main/java/sh/createos/model/SnapshotStatus.java @@ -0,0 +1,26 @@ +package sh.createos.model; + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; +import com.fasterxml.jackson.annotation.JsonValue; + +/** Lifecycle state of a reusable snapshot. */ +public enum SnapshotStatus { + CREATING("creating"), + READY("ready"), + FAILED("failed"), + DELETING("deleting"), + @JsonEnumDefaultValue + UNKNOWN("unknown"); + + private final String wireValue; + + SnapshotStatus(String wireValue) { + this.wireValue = wireValue; + } + + /** Returns the server wire value. */ + @JsonValue + public String wireValue() { + return wireValue; + } +} diff --git a/src/main/java/sh/createos/model/SnapshotType.java b/src/main/java/sh/createos/model/SnapshotType.java new file mode 100644 index 0000000..be1bf13 --- /dev/null +++ b/src/main/java/sh/createos/model/SnapshotType.java @@ -0,0 +1,21 @@ +package sh.createos.model; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** State included in a reusable snapshot. */ +public enum SnapshotType { + DISK("disk"), + FULL("full"); + + private final String wireValue; + + SnapshotType(String wireValue) { + this.wireValue = wireValue; + } + + /** Returns the server wire value. */ + @JsonValue + public String wireValue() { + return wireValue; + } +}