From 300001bba69cc9ef0c139e5123104c737043dbef Mon Sep 17 00:00:00 2001 From: Bruno Grbavac Date: Mon, 27 Jul 2026 14:49:44 +0000 Subject: [PATCH] feat(sdk): warm pool management across typescript, python, go, ruby and java sdks Signed-off-by: Bruno Grbavac --- examples/go/warm_pools/main.go | 58 +++++ examples/java/warm-pools/build.gradle.kts | 24 +++ examples/java/warm-pools/settings.gradle.kts | 14 ++ .../java/io/daytona/examples/WarmPools.java | 32 +++ .../python/warm-pools/_async/warm_pool.py | 29 +++ examples/python/warm-pools/warm_pool.py | 28 +++ examples/ruby/warm-pools/warm_pool.rb | 25 +++ examples/typescript/warm-pools/index.ts | 28 +++ sdk-go/pkg/daytona/client.go | 4 + sdk-go/pkg/daytona/volume_test.go | 1 + sdk-go/pkg/daytona/warm_pool.go | 198 ++++++++++++++++++ sdk-go/pkg/daytona/warm_pool_test.go | 129 ++++++++++++ sdk-go/pkg/types/types.go | 38 ++++ .../src/main/java/io/daytona/sdk/Daytona.java | 11 + .../java/io/daytona/sdk/WarmPoolService.java | 102 +++++++++ .../java/io/daytona/sdk/model/WarmPool.java | 111 ++++++++++ .../io/daytona/sdk/WarmPoolServiceTest.java | 140 +++++++++++++ sdk-python/src/daytona/__init__.py | 4 + sdk-python/src/daytona/_async/daytona.py | 4 + sdk-python/src/daytona/_async/warm_pool.py | 98 +++++++++ sdk-python/src/daytona/_sync/daytona.py | 4 + sdk-python/src/daytona/_sync/warm_pool.py | 98 +++++++++ sdk-python/src/daytona/common/warm_pool.py | 33 +++ sdk-python/tests/test_warm_pool.py | 105 ++++++++++ sdk-ruby/lib/daytona/daytona.rb | 4 + sdk-ruby/lib/daytona/sdk.rb | 2 + sdk-ruby/lib/daytona/warm_pool.rb | 72 +++++++ sdk-ruby/lib/daytona/warm_pool_service.rb | 58 +++++ .../spec/daytona/warm_pool_service_spec.rb | 73 +++++++ sdk-ruby/spec/spec_helper.rb | 21 ++ sdk-typescript/src/Daytona.ts | 5 + sdk-typescript/src/WarmPool.ts | 106 ++++++++++ sdk-typescript/src/__tests__/Daytona.test.ts | 1 + sdk-typescript/src/__tests__/WarmPool.test.ts | 54 +++++ sdk-typescript/src/index.ts | 2 + 35 files changed, 1716 insertions(+) create mode 100644 examples/go/warm_pools/main.go create mode 100644 examples/java/warm-pools/build.gradle.kts create mode 100644 examples/java/warm-pools/settings.gradle.kts create mode 100644 examples/java/warm-pools/src/main/java/io/daytona/examples/WarmPools.java create mode 100644 examples/python/warm-pools/_async/warm_pool.py create mode 100644 examples/python/warm-pools/warm_pool.py create mode 100644 examples/ruby/warm-pools/warm_pool.rb create mode 100644 examples/typescript/warm-pools/index.ts create mode 100644 sdk-go/pkg/daytona/warm_pool.go create mode 100644 sdk-go/pkg/daytona/warm_pool_test.go create mode 100644 sdk-java/src/main/java/io/daytona/sdk/WarmPoolService.java create mode 100644 sdk-java/src/main/java/io/daytona/sdk/model/WarmPool.java create mode 100644 sdk-java/src/test/java/io/daytona/sdk/WarmPoolServiceTest.java create mode 100644 sdk-python/src/daytona/_async/warm_pool.py create mode 100644 sdk-python/src/daytona/_sync/warm_pool.py create mode 100644 sdk-python/src/daytona/common/warm_pool.py create mode 100644 sdk-python/tests/test_warm_pool.py create mode 100644 sdk-ruby/lib/daytona/warm_pool.rb create mode 100644 sdk-ruby/lib/daytona/warm_pool_service.rb create mode 100644 sdk-ruby/spec/daytona/warm_pool_service_spec.rb create mode 100644 sdk-typescript/src/WarmPool.ts create mode 100644 sdk-typescript/src/__tests__/WarmPool.test.ts diff --git a/examples/go/warm_pools/main.go b/examples/go/warm_pools/main.go new file mode 100644 index 000000000..fc4bd2e05 --- /dev/null +++ b/examples/go/warm_pools/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "log" + + "github.com/daytona/clients/sdk-go/pkg/daytona" + "github.com/daytona/clients/sdk-go/pkg/types" +) + +func main() { + // Create a new Daytona client using environment variables. + // Set DAYTONA_API_KEY before running. + client, err := daytona.NewClient() + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + + ctx := context.Background() + + // Create a warm pool that keeps ready-to-use sandboxes for an existing snapshot. + // Target is optional and defaults to the organization default region. + pool, err := client.WarmPool.Create(ctx, &types.CreateWarmPoolParams{ + Snapshot: "my-snapshot", + Pool: 3, + }) + if err != nil { + log.Fatalf("Failed to create warm pool: %v", err) + } + log.Printf("✓ Created warm pool %s for snapshot %q in %s\n", pool.ID, pool.Snapshot, pool.Target) + + // List warm pools. CurrentSize vs Pool is the status check: CurrentSize is the + // number of ready sandboxes; ErrorReason is set when the pool cannot be filled. + pools, err := client.WarmPool.List(ctx) + if err != nil { + log.Fatalf("Failed to list warm pools: %v", err) + } + for _, p := range pools { + status := "" + if p.ErrorReason != nil { + status = " (error: " + *p.ErrorReason + ")" + } + log.Printf("%s (%s): %d/%d ready%s\n", p.Snapshot, p.Target, p.CurrentSize, p.Pool, status) + } + + // Grow the pool. Setting the size to 0 drains it without deleting the pool. + updated, err := client.WarmPool.Update(ctx, pool.ID, 5) + if err != nil { + log.Fatalf("Failed to update warm pool: %v", err) + } + log.Printf("✓ Updated desired size to %d\n", updated.Pool) + + // Cleanup + if err := client.WarmPool.Delete(ctx, pool.ID); err != nil { + log.Fatalf("Failed to delete warm pool: %v", err) + } + log.Println("✓ Deleted warm pool") +} diff --git a/examples/java/warm-pools/build.gradle.kts b/examples/java/warm-pools/build.gradle.kts new file mode 100644 index 000000000..c4269c2a1 --- /dev/null +++ b/examples/java/warm-pools/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + application +} + +group = "io.daytona.examples" +version = "0.1.0" + +java { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +repositories { + mavenLocal() + mavenCentral() +} + +dependencies { + implementation("io.daytona:sdk-java") +} + +application { + mainClass.set("io.daytona.examples.WarmPools") +} diff --git a/examples/java/warm-pools/settings.gradle.kts b/examples/java/warm-pools/settings.gradle.kts new file mode 100644 index 000000000..c012af2f0 --- /dev/null +++ b/examples/java/warm-pools/settings.gradle.kts @@ -0,0 +1,14 @@ +rootProject.name = "warm-pools" + +dependencyResolutionManagement { + repositories { + mavenLocal() + mavenCentral() + } +} + +includeBuild("../../../sdk-java") { + dependencySubstitution { + substitute(module("io.daytona:sdk-java")).using(project(":")) + } +} diff --git a/examples/java/warm-pools/src/main/java/io/daytona/examples/WarmPools.java b/examples/java/warm-pools/src/main/java/io/daytona/examples/WarmPools.java new file mode 100644 index 000000000..6939d144c --- /dev/null +++ b/examples/java/warm-pools/src/main/java/io/daytona/examples/WarmPools.java @@ -0,0 +1,32 @@ +package io.daytona.examples; + +import io.daytona.sdk.Daytona; +import io.daytona.sdk.model.WarmPool; + +public class WarmPools { + public static void main(String[] args) { + try (Daytona daytona = new Daytona()) { + // Create a warm pool that keeps ready-to-use sandboxes for an existing snapshot. + // The target (third argument) is optional; null means the organization default region. + WarmPool pool = daytona.warmPool().create("my-snapshot", 3, null); + System.out.println("Created warm pool " + pool.getId() + + " for snapshot '" + pool.getSnapshot() + "' in " + pool.getTarget()); + + // List warm pools. currentSize vs pool is the status check: currentSize is the + // number of ready sandboxes; errorReason is set when the pool cannot be filled. + for (WarmPool p : daytona.warmPool().list()) { + String status = p.getErrorReason() != null ? " (error: " + p.getErrorReason() + ")" : ""; + System.out.println(p.getSnapshot() + " (" + p.getTarget() + "): " + + p.getCurrentSize() + "/" + p.getPool() + " ready" + status); + } + + // Grow the pool. Setting the size to 0 drains it without deleting the pool. + WarmPool updated = daytona.warmPool().update(pool.getId(), 5); + System.out.println("Updated desired size to " + updated.getPool()); + + // Cleanup + daytona.warmPool().delete(pool.getId()); + System.out.println("Deleted warm pool"); + } + } +} diff --git a/examples/python/warm-pools/_async/warm_pool.py b/examples/python/warm-pools/_async/warm_pool.py new file mode 100644 index 000000000..3733ab00b --- /dev/null +++ b/examples/python/warm-pools/_async/warm_pool.py @@ -0,0 +1,29 @@ +import asyncio + +from daytona import AsyncDaytona + + +async def main(): + async with AsyncDaytona() as daytona: + # Create a warm pool that keeps ready-to-use sandboxes for an existing snapshot. + # `target` is optional and defaults to the organization default region. + pool = await daytona.warm_pool.create("my-snapshot", pool=3) + print(f"Created warm pool {pool.id} for snapshot '{pool.snapshot}' in {pool.target}") + + # List warm pools. current_size vs pool is the status check: current_size is the + # number of ready sandboxes; error_reason is set when the pool cannot be filled. + for p in await daytona.warm_pool.list(): + status = f" (error: {p.error_reason})" if p.error_reason else "" + print(f"{p.snapshot} ({p.target}): {p.current_size}/{p.pool} ready{status}") + + # Grow the pool. Setting pool to 0 drains it without deleting the pool. + updated = await daytona.warm_pool.update(pool.id, pool=5) + print(f"Updated desired size to {updated.pool}") + + # Cleanup + await daytona.warm_pool.delete(pool.id) + print("Deleted warm pool") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/python/warm-pools/warm_pool.py b/examples/python/warm-pools/warm_pool.py new file mode 100644 index 000000000..357e857e9 --- /dev/null +++ b/examples/python/warm-pools/warm_pool.py @@ -0,0 +1,28 @@ +from daytona import Daytona + + +def main(): + daytona = Daytona() + + # Create a warm pool that keeps ready-to-use sandboxes for an existing snapshot. + # `target` is optional and defaults to the organization default region. + pool = daytona.warm_pool.create("my-snapshot", pool=3) + print(f"Created warm pool {pool.id} for snapshot '{pool.snapshot}' in {pool.target}") + + # List warm pools. current_size vs pool is the status check: current_size is the + # number of ready sandboxes; error_reason is set when the pool cannot be filled. + for p in daytona.warm_pool.list(): + status = f" (error: {p.error_reason})" if p.error_reason else "" + print(f"{p.snapshot} ({p.target}): {p.current_size}/{p.pool} ready{status}") + + # Grow the pool. Setting pool to 0 drains it without deleting the pool. + updated = daytona.warm_pool.update(pool.id, pool=5) + print(f"Updated desired size to {updated.pool}") + + # Cleanup + daytona.warm_pool.delete(pool.id) + print("Deleted warm pool") + + +if __name__ == "__main__": + main() diff --git a/examples/ruby/warm-pools/warm_pool.rb b/examples/ruby/warm-pools/warm_pool.rb new file mode 100644 index 000000000..aa0cbb95e --- /dev/null +++ b/examples/ruby/warm-pools/warm_pool.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'daytona' + +daytona = Daytona::Daytona.new + +# Create a warm pool that keeps ready-to-use sandboxes for an existing snapshot. +# `target` is optional and defaults to the organization default region. +pool = daytona.warm_pool.create('my-snapshot', 3) +puts "Created warm pool #{pool.id} for snapshot '#{pool.snapshot}' in #{pool.target}" + +# List warm pools. current_size vs pool is the status check: current_size is the +# number of ready sandboxes; error_reason is set when the pool cannot be filled. +daytona.warm_pool.list.each do |p| + status = p.error_reason ? " (error: #{p.error_reason})" : '' + puts "#{p.snapshot} (#{p.target}): #{p.current_size}/#{p.pool} ready#{status}" +end + +# Grow the pool. Setting the size to 0 drains it without deleting the pool. +updated = daytona.warm_pool.update(pool.id, 5) +puts "Updated desired size to #{updated.pool}" + +# Cleanup +daytona.warm_pool.delete(pool.id) +puts 'Deleted warm pool' diff --git a/examples/typescript/warm-pools/index.ts b/examples/typescript/warm-pools/index.ts new file mode 100644 index 000000000..ce8c3b9ef --- /dev/null +++ b/examples/typescript/warm-pools/index.ts @@ -0,0 +1,28 @@ +import { Daytona } from '@daytona/sdk' + +async function main() { + const daytona = new Daytona() + + // Create a warm pool that keeps ready-to-use sandboxes for an existing snapshot. + // `target` is optional and defaults to the organization default region. + const pool = await daytona.warmPool.create({ snapshot: 'my-snapshot', pool: 3 }) + console.log(`Created warm pool ${pool.id} for snapshot '${pool.snapshot}' in ${pool.target}`) + + // List warm pools. currentSize vs pool is the status check: currentSize is the + // number of ready sandboxes; errorReason is set when the pool cannot be filled. + const pools = await daytona.warmPool.list() + for (const p of pools) { + const status = p.errorReason ? ` (error: ${p.errorReason})` : '' + console.log(`${p.snapshot} (${p.target}): ${p.currentSize}/${p.pool} ready${status}`) + } + + // Grow the pool. Setting pool to 0 drains it without deleting the pool. + const updated = await daytona.warmPool.update(pool.id, { pool: 5 }) + console.log(`Updated desired size to ${updated.pool}`) + + // Cleanup + await daytona.warmPool.delete(pool.id) + console.log('Deleted warm pool') +} + +main() diff --git a/sdk-go/pkg/daytona/client.go b/sdk-go/pkg/daytona/client.go index 82857f3b8..383b73982 100644 --- a/sdk-go/pkg/daytona/client.go +++ b/sdk-go/pkg/daytona/client.go @@ -132,6 +132,9 @@ type Client struct { // Secret provides methods for managing organization secrets. Secret *SecretService + + // WarmPool provides methods for managing warm pools of ready sandboxes. + WarmPool *WarmPoolService } // NewClient creates a new Daytona client with default configuration. @@ -272,6 +275,7 @@ func NewClientWithConfig(config *types.DaytonaConfig) (*Client, error) { client.Volume = NewVolumeService(client) client.Snapshot = NewSnapshotService(client) client.Secret = NewSecretService(client) + client.WarmPool = NewWarmPoolService(client) client.subscriptionManager = common.NewEventSubscriptionManager(nil) token := client.apiKey diff --git a/sdk-go/pkg/daytona/volume_test.go b/sdk-go/pkg/daytona/volume_test.go index 5b88ca9d8..014477246 100644 --- a/sdk-go/pkg/daytona/volume_test.go +++ b/sdk-go/pkg/daytona/volume_test.go @@ -65,6 +65,7 @@ func createTestClientWithServer(t *testing.T, server *httptest.Server) *Client { client.Volume = NewVolumeService(client) client.Snapshot = NewSnapshotService(client) client.Secret = NewSecretService(client) + client.WarmPool = NewWarmPoolService(client) return client } diff --git a/sdk-go/pkg/daytona/warm_pool.go b/sdk-go/pkg/daytona/warm_pool.go new file mode 100644 index 000000000..05b29f4ac --- /dev/null +++ b/sdk-go/pkg/daytona/warm_pool.go @@ -0,0 +1,198 @@ +package daytona + +import ( + "context" + "time" + + apiclient "github.com/daytona/clients/api-client-go" + "github.com/daytona/clients/sdk-go/pkg/errors" + "github.com/daytona/clients/sdk-go/pkg/types" +) + +// WarmPoolService provides warm pool management operations. +// +// WarmPoolService enables listing, creating, updating, and deleting warm +// pools of ready-to-use sandboxes for a snapshot. A pool's CurrentSize versus +// Pool is its status: CurrentSize is the number of ready warm sandboxes, Pool +// is the desired number, and ErrorReason is set when the pool cannot be +// filled. Access through [Client.WarmPool]. +// +// Example: +// +// // Create a new warm pool +// pool, err := client.WarmPool.Create(ctx, &types.CreateWarmPoolParams{ +// Snapshot: "my-snapshot", +// Pool: 5, +// }) +// if err != nil { +// return err +// } +// +// // List all warm pools +// pools, err := client.WarmPool.List(ctx) +type WarmPoolService struct { + client *Client + otel *otelState +} + +// NewWarmPoolService creates a new WarmPoolService. +// +// This is typically called internally by the SDK when creating a [Client]. +// Users should access WarmPoolService through [Client.WarmPool] rather than +// creating it directly. +func NewWarmPoolService(client *Client) *WarmPoolService { + return &WarmPoolService{ + client: client, + otel: client.Otel, + } +} + +// List returns all warm pools in the organization. +// +// Example: +// +// pools, err := client.WarmPool.List(ctx) +// if err != nil { +// return err +// } +// for _, pool := range pools { +// fmt.Printf("%s: %d/%d ready\n", pool.Snapshot, pool.CurrentSize, pool.Pool) +// } +// +// Returns a slice of [types.WarmPool] or an error if the request fails. +func (w *WarmPoolService) List(ctx context.Context) ([]*types.WarmPool, error) { + return withInstrumentation(ctx, w.otel, "WarmPool", "List", func(ctx context.Context) ([]*types.WarmPool, error) { + authCtx := w.client.getAuthContext(ctx) + warmPoolDtos, httpResp, err := w.client.apiClient.WarmPoolsAPI.ListWarmPools(authCtx).Execute() + if err != nil { + return nil, errors.ConvertAPIError(err, httpResp) + } + + warmPools := make([]*types.WarmPool, len(warmPoolDtos)) + for i := range warmPoolDtos { + warmPools[i] = warmPoolDtoToWarmPool(&warmPoolDtos[i]) + } + + return warmPools, nil + }) +} + +// Create creates a new warm pool. +// +// A pool for the same snapshot and region may exist only once; a duplicate +// returns a conflict error. +// +// Parameters: +// - params: Warm pool creation parameters including snapshot, desired pool +// size, and optional target region +// +// Example: +// +// pool, err := client.WarmPool.Create(ctx, &types.CreateWarmPoolParams{ +// Snapshot: "my-snapshot", +// Pool: 5, +// }) +// if err != nil { +// return err +// } +// +// Returns the created [types.WarmPool] or an error. +func (w *WarmPoolService) Create(ctx context.Context, params *types.CreateWarmPoolParams) (*types.WarmPool, error) { + return withInstrumentation(ctx, w.otel, "WarmPool", "Create", func(ctx context.Context) (*types.WarmPool, error) { + authCtx := w.client.getAuthContext(ctx) + + req := apiclient.NewCreateWarmPool(params.Snapshot, float32(params.Pool)) + if params.Target != nil { + req.SetTarget(*params.Target) + } + + warmPoolDto, httpResp, err := w.client.apiClient.WarmPoolsAPI.CreateWarmPool(authCtx).CreateWarmPool(*req).Execute() + if err != nil { + return nil, errors.ConvertAPIError(err, httpResp) + } + + return warmPoolDtoToWarmPool(warmPoolDto), nil + }) +} + +// Update sets the desired size of a warm pool. +// +// Parameters: +// - warmPoolID: The warm pool ID +// - pool: New desired number of warm sandboxes (0 drains the pool) +// +// Example: +// +// pool, err := client.WarmPool.Update(ctx, warmPoolID, 10) +// if err != nil { +// return err +// } +// +// Returns the updated [types.WarmPool] or an error if the ID is unknown (404). +func (w *WarmPoolService) Update(ctx context.Context, warmPoolID string, pool int) (*types.WarmPool, error) { + return withInstrumentation(ctx, w.otel, "WarmPool", "Update", func(ctx context.Context) (*types.WarmPool, error) { + authCtx := w.client.getAuthContext(ctx) + + req := apiclient.NewUpdateWarmPool(float32(pool)) + warmPoolDto, httpResp, err := w.client.apiClient.WarmPoolsAPI.UpdateWarmPool(authCtx, warmPoolID).UpdateWarmPool(*req).Execute() + if err != nil { + return nil, errors.ConvertAPIError(err, httpResp) + } + + return warmPoolDtoToWarmPool(warmPoolDto), nil + }) +} + +// Delete permanently removes a warm pool. +// +// Parameters: +// - warmPoolID: The warm pool ID +// +// Example: +// +// err := client.WarmPool.Delete(ctx, warmPoolID) +// if err != nil { +// return err +// } +// +// Returns an error if the ID is unknown (404) or deletion fails. +func (w *WarmPoolService) Delete(ctx context.Context, warmPoolID string) error { + return withInstrumentationVoid(ctx, w.otel, "WarmPool", "Delete", func(ctx context.Context) error { + authCtx := w.client.getAuthContext(ctx) + httpResp, err := w.client.apiClient.WarmPoolsAPI.DeleteWarmPool(authCtx, warmPoolID).Execute() + if err != nil { + return errors.ConvertAPIError(err, httpResp) + } + + return nil + }) +} + +// warmPoolDtoToWarmPool converts api-client WarmPool to SDK types.WarmPool +func warmPoolDtoToWarmPool(dto *apiclient.WarmPool) *types.WarmPool { + createdAt, _ := time.Parse(time.RFC3339, dto.GetCreatedAt()) + updatedAt, _ := time.Parse(time.RFC3339, dto.GetUpdatedAt()) + + warmPool := &types.WarmPool{ + ID: dto.GetId(), + OrganizationID: dto.GetOrganizationId(), + Snapshot: dto.GetSnapshot(), + Target: dto.GetTarget(), + Pool: int(dto.GetPool()), + CurrentSize: int(dto.GetCurrentSize()), + CPU: int(dto.GetCpu()), + Mem: int(dto.GetMem()), + Disk: int(dto.GetDisk()), + OsUser: dto.GetOsUser(), + Env: dto.GetEnv(), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } + + // Handle nullable ErrorReason + if dto.ErrorReason.IsSet() { + warmPool.ErrorReason = dto.ErrorReason.Get() + } + + return warmPool +} diff --git a/sdk-go/pkg/daytona/warm_pool_test.go b/sdk-go/pkg/daytona/warm_pool_test.go new file mode 100644 index 000000000..4876264ed --- /dev/null +++ b/sdk-go/pkg/daytona/warm_pool_test.go @@ -0,0 +1,129 @@ +package daytona + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + apiclient "github.com/daytona/clients/api-client-go" + "github.com/daytona/clients/sdk-go/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWarmPoolServiceCreation(t *testing.T) { + t.Setenv("DAYTONA_API_KEY", "test-api-key") + t.Setenv("DAYTONA_API_URL", "") + t.Setenv("DAYTONA_JWT_TOKEN", "") + t.Setenv("DAYTONA_ORGANIZATION_ID", "") + + client, err := NewClient() + require.NoError(t, err) + + ws := NewWarmPoolService(client) + require.NotNil(t, ws) +} + +func TestWarmPoolList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": "wp-1", + "organizationId": "org-1", + "snapshot": "my-snapshot", + "target": "us", + "pool": 5, + "currentSize": 3, + "cpu": 2, + "mem": 4, + "disk": 10, + "osUser": "daytona", + "env": map[string]string{}, + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-02T00:00:00Z", + }}) + })) + defer server.Close() + + client := createTestClientWithServer(t, server) + + pools, err := client.WarmPool.List(context.Background()) + require.NoError(t, err) + require.Len(t, pools, 1) + assert.Equal(t, "wp-1", pools[0].ID) + assert.Equal(t, "my-snapshot", pools[0].Snapshot) + assert.Equal(t, 5, pools[0].Pool) + assert.Equal(t, 3, pools[0].CurrentSize) +} + +func TestWarmPoolListError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "internal error"}) + })) + defer server.Close() + + client := createTestClientWithServer(t, server) + + _, err := client.WarmPool.List(context.Background()) + require.Error(t, err) +} + +func TestWarmPoolCreateError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "warm pool already exists"}) + })) + defer server.Close() + + client := createTestClientWithServer(t, server) + + _, err := client.WarmPool.Create(context.Background(), &types.CreateWarmPoolParams{ + Snapshot: "my-snapshot", + Pool: 5, + }) + require.Error(t, err) +} + +func TestWarmPoolDeleteError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "not found"}) + })) + defer server.Close() + + client := createTestClientWithServer(t, server) + + err := client.WarmPool.Delete(context.Background(), "wp-1") + require.Error(t, err) +} + +func TestWarmPoolDtoToWarmPool(t *testing.T) { + dto := apiclient.NewWarmPool( + "wp-1", "org-1", "my-snapshot", "us", 5, 3, 2, 4, 10, "daytona", + map[string]string{"FOO": "bar"}, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", + ) + dto.SetErrorReason("quota exceeded") + + warmPool := warmPoolDtoToWarmPool(dto) + + assert.Equal(t, "wp-1", warmPool.ID) + assert.Equal(t, "org-1", warmPool.OrganizationID) + assert.Equal(t, "my-snapshot", warmPool.Snapshot) + assert.Equal(t, "us", warmPool.Target) + assert.Equal(t, 5, warmPool.Pool) + assert.Equal(t, 3, warmPool.CurrentSize) + assert.Equal(t, 2, warmPool.CPU) + assert.Equal(t, 4, warmPool.Mem) + assert.Equal(t, 10, warmPool.Disk) + assert.Equal(t, "daytona", warmPool.OsUser) + assert.Equal(t, map[string]string{"FOO": "bar"}, warmPool.Env) + require.NotNil(t, warmPool.ErrorReason) + assert.Equal(t, "quota exceeded", *warmPool.ErrorReason) + assert.Equal(t, "2025-01-01T00:00:00Z", warmPool.CreatedAt.Format("2006-01-02T15:04:05Z")) +} diff --git a/sdk-go/pkg/types/types.go b/sdk-go/pkg/types/types.go index 855b8b2e9..035df2c87 100644 --- a/sdk-go/pkg/types/types.go +++ b/sdk-go/pkg/types/types.go @@ -236,6 +236,44 @@ type ListSecretsResponse struct { NextCursor *string } +// WarmPool represents a warm pool of ready-to-use sandboxes for a snapshot. +// +// CurrentSize versus Pool is the pool's status: CurrentSize is the number of +// ready warm sandboxes, Pool is the desired number. ErrorReason is set when +// the pool cannot be filled. +type WarmPool struct { + ID string `json:"id"` + OrganizationID string `json:"organizationId"` + // Snapshot is the snapshot the pool keeps warm sandboxes for. + Snapshot string `json:"snapshot"` + // Target is the target region of the pool. + Target string `json:"target"` + // Pool is the desired number of warm sandboxes. + Pool int `json:"pool"` + // CurrentSize is the current number of ready warm sandboxes in the pool. + CurrentSize int `json:"currentSize"` + CPU int `json:"cpu"` + Mem int `json:"mem"` + Disk int `json:"disk"` + OsUser string `json:"osUser"` + Env map[string]string `json:"env"` + // ErrorReason is set when the pool cannot be filled. + ErrorReason *string `json:"errorReason,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// CreateWarmPoolParams contains parameters for creating a warm pool. +type CreateWarmPoolParams struct { + // Snapshot is the snapshot (ID or name) to keep warm sandboxes for. + Snapshot string + // Pool is the number of warm sandboxes to keep ready. + Pool int + // Target is the target region for the warm pool. Defaults to the + // organization default region when nil. + Target *string +} + // Snapshot represents a Daytona snapshot type Snapshot struct { ID string `json:"id"` diff --git a/sdk-java/src/main/java/io/daytona/sdk/Daytona.java b/sdk-java/src/main/java/io/daytona/sdk/Daytona.java index bb6d1a407..7ada667ba 100644 --- a/sdk-java/src/main/java/io/daytona/sdk/Daytona.java +++ b/sdk-java/src/main/java/io/daytona/sdk/Daytona.java @@ -54,6 +54,7 @@ public class Daytona implements AutoCloseable { private final SnapshotService snapshot; private final VolumeService volume; private final SecretService secret; + private final WarmPoolService warmPool; private final EventDispatcher eventDispatcher; private final EventSubscriptionManager subscriptionManager; private final ObjectMapper objectMapper = new ObjectMapper(); @@ -93,6 +94,7 @@ public Daytona(DaytonaConfig config) { this.snapshot = new SnapshotService(new io.daytona.api.client.api.SnapshotsApi(apiClient), apiClient.getHttpClient(), config.getApiKey()); this.volume = new VolumeService(new io.daytona.api.client.api.VolumesApi(apiClient)); this.secret = new SecretService(new io.daytona.api.client.api.SecretApi(apiClient)); + this.warmPool = new WarmPoolService(new io.daytona.api.client.api.WarmPoolsApi(apiClient)); @SuppressWarnings("deprecation") boolean legacyPolling = config.isUseDeprecatedPolling(); if (!legacyPolling) { @@ -535,6 +537,15 @@ public SecretService secret() { return secret; } + /** + * Returns Warm Pool management service. + * + * @return warm pool service instance + */ + public WarmPoolService warmPool() { + return warmPool; + } + /** * Closes this client and releases underlying HTTP resources. */ diff --git a/sdk-java/src/main/java/io/daytona/sdk/WarmPoolService.java b/sdk-java/src/main/java/io/daytona/sdk/WarmPoolService.java new file mode 100644 index 000000000..9f2d3fece --- /dev/null +++ b/sdk-java/src/main/java/io/daytona/sdk/WarmPoolService.java @@ -0,0 +1,102 @@ +package io.daytona.sdk; + +import io.daytona.api.client.api.WarmPoolsApi; +import io.daytona.api.client.model.CreateWarmPool; +import io.daytona.api.client.model.UpdateWarmPool; +import io.daytona.sdk.model.WarmPool; + +import java.math.BigDecimal; +import java.util.List; +import java.util.ArrayList; + +/** + * Service for managing Daytona Warm Pools. + * + *

Warm pools keep ready-to-use Sandboxes for a snapshot. A pool's {@code currentSize} versus + * {@code pool} is its status: {@code currentSize} is the number of ready warm sandboxes, + * {@code pool} is the desired number, and {@code errorReason} is set when the pool cannot be + * filled. + */ +public class WarmPoolService { + private final WarmPoolsApi warmPoolsApi; + + WarmPoolService(WarmPoolsApi warmPoolsApi) { + this.warmPoolsApi = warmPoolsApi; + } + + /** + * Lists all warm pools in the organization. + * + * @return list of warm pools + * @throws io.daytona.sdk.exception.DaytonaException if the API request fails + */ + public List list() { + List warmPools = ExceptionMapper.callMain(() -> warmPoolsApi.listWarmPools(null)); + List result = new ArrayList(); + if (warmPools != null) { + for (io.daytona.api.client.model.WarmPool warmPool : warmPools) { + result.add(toWarmPool(warmPool)); + } + } + return result; + } + + /** + * Creates a new warm pool. + * + * @param snapshot snapshot (ID or name) to keep warm sandboxes for + * @param pool number of warm sandboxes to keep ready + * @param target target region, or {@code null} for the organization default region + * @return created {@link WarmPool} + * @throws io.daytona.sdk.exception.DaytonaException if a pool for the same snapshot and + * region already exists or creation fails + */ + public WarmPool create(String snapshot, int pool, String target) { + CreateWarmPool request = new CreateWarmPool().snapshot(snapshot).pool(BigDecimal.valueOf(pool)); + if (target != null) { + request.target(target); + } + io.daytona.api.client.model.WarmPool warmPoolDto = ExceptionMapper.callMain( + () -> warmPoolsApi.createWarmPool(request, null) + ); + return toWarmPool(warmPoolDto); + } + + /** + * Updates the desired size of a warm pool. + * + * @param id warm pool identifier + * @param pool new desired number of warm sandboxes (0 drains the pool) + * @return updated {@link WarmPool} + * @throws io.daytona.sdk.exception.DaytonaException if no warm pool is found or request fails + */ + public WarmPool update(String id, int pool) { + io.daytona.api.client.model.WarmPool warmPoolDto = ExceptionMapper.callMain( + () -> warmPoolsApi.updateWarmPool(id, new UpdateWarmPool().pool(BigDecimal.valueOf(pool)), null) + ); + return toWarmPool(warmPoolDto); + } + + /** + * Deletes a warm pool by ID. + * + * @param id warm pool identifier + * @throws io.daytona.sdk.exception.DaytonaException if deletion fails + */ + public void delete(String id) { + ExceptionMapper.runMain(() -> warmPoolsApi.deleteWarmPool(id, null)); + } + + private WarmPool toWarmPool(io.daytona.api.client.model.WarmPool source) { + WarmPool warmPool = new WarmPool(); + if (source != null) { + warmPool.setId(source.getId()); + warmPool.setSnapshot(source.getSnapshot()); + warmPool.setTarget(source.getTarget()); + warmPool.setPool(source.getPool() == null ? 0 : source.getPool().intValue()); + warmPool.setCurrentSize(source.getCurrentSize() == null ? 0 : source.getCurrentSize().intValue()); + warmPool.setErrorReason(source.getErrorReason()); + } + return warmPool; + } +} diff --git a/sdk-java/src/main/java/io/daytona/sdk/model/WarmPool.java b/sdk-java/src/main/java/io/daytona/sdk/model/WarmPool.java new file mode 100644 index 000000000..b59bc0137 --- /dev/null +++ b/sdk-java/src/main/java/io/daytona/sdk/model/WarmPool.java @@ -0,0 +1,111 @@ +package io.daytona.sdk.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +/** + * Warm pool metadata returned by Daytona APIs. + * + *

{@code currentSize} versus {@code pool} is the pool's status: {@code currentSize} is the + * number of ready warm sandboxes, {@code pool} is the desired number. {@code errorReason} is + * set when the pool cannot be filled. + */ +public class WarmPool { + @JsonProperty("id") + private String id; + @JsonProperty("snapshot") + private String snapshot; + @JsonProperty("target") + private String target; + @JsonProperty("pool") + private int pool; + @JsonProperty("currentSize") + private int currentSize; + @JsonProperty("errorReason") + private String errorReason; + + /** + * Returns warm pool identifier. + * + * @return warm pool ID + */ + public String getId() { return id; } + + /** + * Sets warm pool identifier. + * + * @param id warm pool ID + */ + public void setId(String id) { this.id = id; } + + /** + * Returns the snapshot the pool keeps warm sandboxes for. + * + * @return snapshot ID or name + */ + public String getSnapshot() { return snapshot; } + + /** + * Sets the snapshot the pool keeps warm sandboxes for. + * + * @param snapshot snapshot ID or name + */ + public void setSnapshot(String snapshot) { this.snapshot = snapshot; } + + /** + * Returns the target region of the pool. + * + * @return target region + */ + public String getTarget() { return target; } + + /** + * Sets the target region of the pool. + * + * @param target target region + */ + public void setTarget(String target) { this.target = target; } + + /** + * Returns the desired number of warm sandboxes. + * + * @return desired pool size + */ + public int getPool() { return pool; } + + /** + * Sets the desired number of warm sandboxes. + * + * @param pool desired pool size + */ + public void setPool(int pool) { this.pool = pool; } + + /** + * Returns the current number of ready warm sandboxes in the pool. + * + * @return current pool size + */ + public int getCurrentSize() { return currentSize; } + + /** + * Sets the current number of ready warm sandboxes in the pool. + * + * @param currentSize current pool size + */ + public void setCurrentSize(int currentSize) { this.currentSize = currentSize; } + + /** + * Returns the reason the pool cannot be filled, if any. + * + * @return error reason or {@code null} + */ + public String getErrorReason() { return errorReason; } + + /** + * Sets the reason the pool cannot be filled. + * + * @param errorReason error reason + */ + public void setErrorReason(String errorReason) { this.errorReason = errorReason; } +} diff --git a/sdk-java/src/test/java/io/daytona/sdk/WarmPoolServiceTest.java b/sdk-java/src/test/java/io/daytona/sdk/WarmPoolServiceTest.java new file mode 100644 index 000000000..8daf60483 --- /dev/null +++ b/sdk-java/src/test/java/io/daytona/sdk/WarmPoolServiceTest.java @@ -0,0 +1,140 @@ +package io.daytona.sdk; + +import io.daytona.api.client.api.WarmPoolsApi; +import io.daytona.api.client.model.CreateWarmPool; +import io.daytona.api.client.model.UpdateWarmPool; +import io.daytona.sdk.exception.DaytonaNotFoundException; +import io.daytona.sdk.exception.DaytonaServerException; +import io.daytona.sdk.model.WarmPool; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarmPoolServiceTest { + + @Mock + private WarmPoolsApi warmPoolsApi; + + private WarmPoolService service; + + @BeforeEach + void setUp() { + service = new WarmPoolService(warmPoolsApi); + } + + @Test + void createMapsResponse() { + when(warmPoolsApi.createWarmPool(any(), isNull())).thenReturn(warmPoolDto("wp-1", "snap", 5, 3)); + + WarmPool warmPool = service.create("snap", 5, null); + + assertThat(warmPool.getId()).isEqualTo("wp-1"); + assertThat(warmPool.getSnapshot()).isEqualTo("snap"); + assertThat(warmPool.getPool()).isEqualTo(5); + assertThat(warmPool.getCurrentSize()).isEqualTo(3); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CreateWarmPool.class); + verify(warmPoolsApi).createWarmPool(captor.capture(), isNull()); + assertThat(captor.getValue().getSnapshot()).isEqualTo("snap"); + assertThat(captor.getValue().getPool()).isEqualTo(BigDecimal.valueOf(5)); + assertThat(captor.getValue().getTarget()).isNull(); + } + + @Test + void createPassesTargetWhenGiven() { + when(warmPoolsApi.createWarmPool(any(), isNull())).thenReturn(warmPoolDto("wp-1", "snap", 5, 0)); + + service.create("snap", 5, "eu"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CreateWarmPool.class); + verify(warmPoolsApi).createWarmPool(captor.capture(), isNull()); + assertThat(captor.getValue().getTarget()).isEqualTo("eu"); + } + + @Test + void listMapsAllItems() { + when(warmPoolsApi.listWarmPools(isNull())).thenReturn(Arrays.asList( + warmPoolDto("wp-1", "snap-a", 5, 5), + warmPoolDto("wp-2", "snap-b", 3, 1) + )); + + assertThat(service.list()) + .extracting(WarmPool::getSnapshot) + .containsExactly("snap-a", "snap-b"); + } + + @Test + void listReturnsEmptyListWhenApiReturnsNull() { + when(warmPoolsApi.listWarmPools(isNull())).thenReturn(null); + + assertThat(service.list()).isEqualTo(Collections.emptyList()); + } + + @Test + void updateMapsResponse() { + when(warmPoolsApi.updateWarmPool(eq("wp-1"), any(), isNull())).thenReturn(warmPoolDto("wp-1", "snap", 10, 3)); + + WarmPool warmPool = service.update("wp-1", 10); + + assertThat(warmPool.getPool()).isEqualTo(10); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UpdateWarmPool.class); + verify(warmPoolsApi).updateWarmPool(eq("wp-1"), captor.capture(), isNull()); + assertThat(captor.getValue().getPool()).isEqualTo(BigDecimal.valueOf(10)); + } + + @Test + void deleteDelegatesToApi() { + service.delete("wp-1"); + + verify(warmPoolsApi).deleteWarmPool("wp-1", null); + } + + @ParameterizedTest + @MethodSource("mappedMainApiExceptions") + void updateMapsApiErrors(int status, Class type) { + when(warmPoolsApi.updateWarmPool(eq("wp-1"), any(), isNull())) + .thenThrow(new io.daytona.api.client.ApiException(status, "boom", null, "{\"message\":\"mapped\"}")); + + assertThatThrownBy(() -> service.update("wp-1", 1)) + .isInstanceOf(type) + .hasMessage("mapped"); + } + + private static Stream mappedMainApiExceptions() { + return Stream.of( + Arguments.of(404, DaytonaNotFoundException.class), + Arguments.of(500, DaytonaServerException.class) + ); + } + + private static io.daytona.api.client.model.WarmPool warmPoolDto(String id, String snapshot, int pool, int currentSize) { + io.daytona.api.client.model.WarmPool dto = new io.daytona.api.client.model.WarmPool(); + dto.setId(id); + dto.setSnapshot(snapshot); + dto.setTarget("us"); + dto.setPool(BigDecimal.valueOf(pool)); + dto.setCurrentSize(BigDecimal.valueOf(currentSize)); + return dto; + } +} diff --git a/sdk-python/src/daytona/__init__.py b/sdk-python/src/daytona/__init__.py index 87016a444..cca78a0ba 100644 --- a/sdk-python/src/daytona/__init__.py +++ b/sdk-python/src/daytona/__init__.py @@ -99,6 +99,7 @@ from .common.secret import CreateSecretParams, ListSecretsResponse, Secret, UpdateSecretParams from .common.snapshot import CreateSnapshotParams from .common.volume import VolumeMount + from .common.warm_pool import WarmPool __all__ = [ "Daytona", @@ -135,6 +136,7 @@ "CancelEvent", "FileUpload", "VolumeMount", + "WarmPool", "Secret", "CreateSecretParams", "UpdateSecretParams", @@ -307,6 +309,8 @@ "CreateSnapshotParams": "common.snapshot", # common.volume "VolumeMount": "common.volume", + # common.warm_pool + "WarmPool": "common.warm_pool", # common.secret "Secret": "common.secret", "CreateSecretParams": "common.secret", diff --git a/sdk-python/src/daytona/_async/daytona.py b/sdk-python/src/daytona/_async/daytona.py index 69eaef990..e411cc6ee 100644 --- a/sdk-python/src/daytona/_async/daytona.py +++ b/sdk-python/src/daytona/_async/daytona.py @@ -40,6 +40,7 @@ SnapshotsApi, ) from daytona_api_client_async import VolumesApi as VolumesApi +from daytona_api_client_async import WarmPoolsApi from daytona_toolbox_api_client_async import ApiClient as ToolboxApiClient from .._utils.enum import to_enum @@ -67,6 +68,7 @@ from .secret import AsyncSecretService from .snapshot import AsyncSnapshotService from .volume import AsyncVolumeService +from .warm_pool import AsyncWarmPoolService _MISSING_HAPPY_EYEBALLS_DELAY = object() @@ -112,6 +114,7 @@ class AsyncDaytona: volume (AsyncVolumeService): Service for managing volumes. snapshot (AsyncSnapshotService): Service for managing snapshots. secret (AsyncSecretService): Service for managing secrets. + warm_pool (AsyncWarmPoolService): Service for managing warm pools. Example: Using environment variables: @@ -312,6 +315,7 @@ def __init__(self, config: DaytonaConfig | None = None): self._shared_session, ) self.secret: AsyncSecretService = AsyncSecretService(SecretApi(self._api_client)) + self.warm_pool: AsyncWarmPoolService = AsyncWarmPoolService(WarmPoolsApi(self._api_client)) use_deprecated_polling = resolve_bool_flag( config.use_deprecated_polling if config else None, diff --git a/sdk-python/src/daytona/_async/warm_pool.py b/sdk-python/src/daytona/_async/warm_pool.py new file mode 100644 index 000000000..90c3516a6 --- /dev/null +++ b/sdk-python/src/daytona/_async/warm_pool.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from daytona_api_client_async import CreateWarmPool, UpdateWarmPool, WarmPoolsApi + +from .._utils.otel_decorator import with_instrumentation +from ..common.warm_pool import WarmPool + + +class AsyncWarmPoolService: + """Service for managing Daytona Warm Pools. Can be used to list, create, update and delete Warm Pools.""" + + def __init__(self, warm_pools_api: WarmPoolsApi): + self.__warm_pools_api = warm_pools_api + + @with_instrumentation() + async def list(self) -> list[WarmPool]: + """List all Warm Pools in the organization. + + Returns: + list[WarmPool]: List of all Warm Pools. + + Example: + ```python + async with AsyncDaytona() as daytona: + pools = await daytona.warm_pool.list() + for pool in pools: + print(f"{pool.snapshot}: {pool.current_size}/{pool.pool} ready") + ``` + """ + return [WarmPool.from_dto(pool) for pool in await self.__warm_pools_api.list_warm_pools()] + + @with_instrumentation() + async def create(self, snapshot: str, pool: int, target: str | None = None) -> WarmPool: + """Create a new Warm Pool. + + Args: + snapshot (str): The snapshot (ID or name) to keep warm sandboxes for. + pool (int): Number of warm sandboxes to keep ready. + target (str | None): Target region for the Warm Pool. Defaults to the + organization default region. + + Returns: + WarmPool: The newly created Warm Pool. + + Raises: + ApiException: If a Warm Pool for the same snapshot and region already exists (409). + + Example: + ```python + async with AsyncDaytona() as daytona: + pool = await daytona.warm_pool.create("my-snapshot", pool=5) + print(f"Created warm pool {pool.id} in {pool.target}") + ``` + """ + return WarmPool.from_dto( + await self.__warm_pools_api.create_warm_pool(CreateWarmPool(snapshot=snapshot, pool=pool, target=target)) + ) + + @with_instrumentation() + async def update(self, warm_pool_id: str, pool: int) -> WarmPool: + """Update the desired size of a Warm Pool. + + Args: + warm_pool_id (str): ID of the Warm Pool to update. + pool (int): New desired number of warm sandboxes (0 drains the pool). + + Returns: + WarmPool: The updated Warm Pool. + + Raises: + NotFoundException: If the Warm Pool does not exist. + + Example: + ```python + async with AsyncDaytona() as daytona: + pool = await daytona.warm_pool.update("warm-pool-id", pool=10) + ``` + """ + return WarmPool.from_dto(await self.__warm_pools_api.update_warm_pool(warm_pool_id, UpdateWarmPool(pool=pool))) + + @with_instrumentation() + async def delete(self, warm_pool_id: str) -> None: + """Delete a Warm Pool. + + Args: + warm_pool_id (str): ID of the Warm Pool to delete. + + Raises: + NotFoundException: If the Warm Pool does not exist. + + Example: + ```python + async with AsyncDaytona() as daytona: + await daytona.warm_pool.delete("warm-pool-id") + print("Warm pool deleted") + ``` + """ + await self.__warm_pools_api.delete_warm_pool(warm_pool_id) diff --git a/sdk-python/src/daytona/_sync/daytona.py b/sdk-python/src/daytona/_sync/daytona.py index 89fdc5aba..35ac0fdc9 100644 --- a/sdk-python/src/daytona/_sync/daytona.py +++ b/sdk-python/src/daytona/_sync/daytona.py @@ -26,6 +26,7 @@ from daytona_api_client import GpuType as SyncGpuType from daytona_api_client import ObjectStorageApi, SandboxApi, SandboxState, SandboxVolume, SecretApi, SnapshotsApi from daytona_api_client import VolumesApi as VolumesApi +from daytona_api_client import WarmPoolsApi from daytona_toolbox_api_client import ApiClient as ToolboxApiClient from .._utils.enum import to_enum @@ -53,6 +54,7 @@ from .secret import SecretService from .snapshot import SnapshotService from .volume import VolumeService +from .warm_pool import WarmPoolService class Daytona: @@ -65,6 +67,7 @@ class Daytona: volume (VolumeService): Service for managing volumes. snapshot (SnapshotService): Service for managing snapshots. secret (SecretService): Service for managing secrets. + warm_pool (WarmPoolService): Service for managing warm pools. Example: Using environment variables: @@ -244,6 +247,7 @@ def __init__(self, config: DaytonaConfig | None = None): SnapshotsApi(self._api_client), self._object_storage_api, self._target ) self.secret: SecretService = SecretService(SecretApi(self._api_client)) + self.warm_pool: WarmPoolService = WarmPoolService(WarmPoolsApi(self._api_client)) env = env_reader or DaytonaEnvReader() use_deprecated_polling = resolve_bool_flag( diff --git a/sdk-python/src/daytona/_sync/warm_pool.py b/sdk-python/src/daytona/_sync/warm_pool.py new file mode 100644 index 000000000..65c86651f --- /dev/null +++ b/sdk-python/src/daytona/_sync/warm_pool.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from daytona_api_client import CreateWarmPool, UpdateWarmPool, WarmPoolsApi + +from .._utils.otel_decorator import with_instrumentation +from ..common.warm_pool import WarmPool + + +class WarmPoolService: + """Service for managing Daytona Warm Pools. Can be used to list, create, update and delete Warm Pools.""" + + def __init__(self, warm_pools_api: WarmPoolsApi): + self.__warm_pools_api = warm_pools_api + + @with_instrumentation() + def list(self) -> list[WarmPool]: + """List all Warm Pools in the organization. + + Returns: + list[WarmPool]: List of all Warm Pools. + + Example: + ```python + daytona = Daytona() + pools = daytona.warm_pool.list() + for pool in pools: + print(f"{pool.snapshot}: {pool.current_size}/{pool.pool} ready") + ``` + """ + return [WarmPool.from_dto(pool) for pool in self.__warm_pools_api.list_warm_pools()] + + @with_instrumentation() + def create(self, snapshot: str, pool: int, target: str | None = None) -> WarmPool: + """Create a new Warm Pool. + + Args: + snapshot (str): The snapshot (ID or name) to keep warm sandboxes for. + pool (int): Number of warm sandboxes to keep ready. + target (str | None): Target region for the Warm Pool. Defaults to the + organization default region. + + Returns: + WarmPool: The newly created Warm Pool. + + Raises: + ApiException: If a Warm Pool for the same snapshot and region already exists (409). + + Example: + ```python + daytona = Daytona() + pool = daytona.warm_pool.create("my-snapshot", pool=5) + print(f"Created warm pool {pool.id} in {pool.target}") + ``` + """ + return WarmPool.from_dto( + self.__warm_pools_api.create_warm_pool(CreateWarmPool(snapshot=snapshot, pool=pool, target=target)) + ) + + @with_instrumentation() + def update(self, warm_pool_id: str, pool: int) -> WarmPool: + """Update the desired size of a Warm Pool. + + Args: + warm_pool_id (str): ID of the Warm Pool to update. + pool (int): New desired number of warm sandboxes (0 drains the pool). + + Returns: + WarmPool: The updated Warm Pool. + + Raises: + NotFoundException: If the Warm Pool does not exist. + + Example: + ```python + daytona = Daytona() + pool = daytona.warm_pool.update("warm-pool-id", pool=10) + ``` + """ + return WarmPool.from_dto(self.__warm_pools_api.update_warm_pool(warm_pool_id, UpdateWarmPool(pool=pool))) + + @with_instrumentation() + def delete(self, warm_pool_id: str) -> None: + """Delete a Warm Pool. + + Args: + warm_pool_id (str): ID of the Warm Pool to delete. + + Raises: + NotFoundException: If the Warm Pool does not exist. + + Example: + ```python + daytona = Daytona() + daytona.warm_pool.delete("warm-pool-id") + print("Warm pool deleted") + ``` + """ + self.__warm_pools_api.delete_warm_pool(warm_pool_id) diff --git a/sdk-python/src/daytona/common/warm_pool.py b/sdk-python/src/daytona/common/warm_pool.py new file mode 100644 index 000000000..ef2c714e3 --- /dev/null +++ b/sdk-python/src/daytona/common/warm_pool.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from daytona_api_client import WarmPool as WarmPoolDto +from daytona_api_client_async import WarmPool as AsyncWarmPoolDto + + +class WarmPool(WarmPoolDto): + """Represents a Daytona Warm Pool which keeps ready-to-use Sandboxes for a snapshot. + + ``current_size`` versus ``pool`` is the pool's status: ``current_size`` is the number + of ready warm sandboxes, ``pool`` is the desired number. ``error_reason`` is set when + the pool cannot be filled. + + Attributes: + id (str): Unique identifier for the Warm Pool. + organization_id (str): Organization ID that owns the Warm Pool. + snapshot (str): Snapshot the pool keeps warm sandboxes for. + target (str): Target region of the pool. + pool (int): Desired number of warm sandboxes. + current_size (int): Current number of ready warm sandboxes in the pool. + cpu (int): CPU cores per sandbox. + mem (int): Memory per sandbox in GiB. + disk (int): Disk per sandbox in GiB. + os_user (str): OS user of the warm sandboxes. + env (dict[str, str]): Environment variables of the warm sandboxes. + error_reason (str | None): Reason the pool cannot be filled, if any. + created_at (str): Date and time when the Warm Pool was created. + updated_at (str): Date and time when the Warm Pool was last updated. + """ + + @classmethod + def from_dto(cls, dto: WarmPoolDto | AsyncWarmPoolDto) -> "WarmPool": + return cls.model_validate(dto.model_dump()) diff --git a/sdk-python/tests/test_warm_pool.py b/sdk-python/tests/test_warm_pool.py new file mode 100644 index 000000000..6b34e60cc --- /dev/null +++ b/sdk-python/tests/test_warm_pool.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from daytona.common.warm_pool import WarmPool +from daytona_api_client import WarmPool as WarmPoolDto + + +def _make_warm_pool_dto(pool_id="wp-123", pool=3): + return WarmPoolDto( + id=pool_id, + organization_id="org-1", + snapshot="test-snapshot", + target="us", + pool=pool, + current_size=1, + cpu=2, + mem=4, + disk=10, + os_user="daytona", + env={}, + error_reason=None, + created_at="2025-01-01T00:00:00Z", + updated_at="2025-01-01T00:00:00Z", + ) + + +class TestSyncWarmPoolService: + def _make_service(self): + from daytona._sync.warm_pool import WarmPoolService + + mock_api = MagicMock() + return WarmPoolService(mock_api), mock_api + + def test_list(self): + service, api = self._make_service() + api.list_warm_pools.return_value = [_make_warm_pool_dto()] + result = service.list() + assert len(result) == 1 + assert isinstance(result[0], WarmPool) + + def test_create(self): + service, api = self._make_service() + api.create_warm_pool.return_value = _make_warm_pool_dto(pool=5) + result = service.create("test-snapshot", pool=5) + assert isinstance(result, WarmPool) + create_dto = api.create_warm_pool.call_args.args[0] + assert create_dto.snapshot == "test-snapshot" + assert create_dto.pool == 5 + assert create_dto.target is None + + def test_update(self): + service, api = self._make_service() + api.update_warm_pool.return_value = _make_warm_pool_dto(pool=10) + result = service.update("wp-123", pool=10) + assert isinstance(result, WarmPool) + update_args = api.update_warm_pool.call_args.args + assert update_args[0] == "wp-123" + assert update_args[1].pool == 10 + + def test_delete(self): + service, api = self._make_service() + api.delete_warm_pool.return_value = None + service.delete("wp-123") + api.delete_warm_pool.assert_called_once_with("wp-123") + + +class TestAsyncWarmPoolService: + def _make_service(self): + from daytona._async.warm_pool import AsyncWarmPoolService + + mock_api = AsyncMock() + return AsyncWarmPoolService(mock_api), mock_api + + @pytest.mark.asyncio + async def test_list(self): + service, api = self._make_service() + api.list_warm_pools.return_value = [_make_warm_pool_dto()] + result = await service.list() + assert len(result) == 1 + assert isinstance(result[0], WarmPool) + + @pytest.mark.asyncio + async def test_create(self): + service, api = self._make_service() + api.create_warm_pool.return_value = _make_warm_pool_dto(pool=5) + result = await service.create("test-snapshot", pool=5, target="us") + assert isinstance(result, WarmPool) + create_dto = api.create_warm_pool.call_args.args[0] + assert create_dto.target == "us" + + @pytest.mark.asyncio + async def test_update(self): + service, api = self._make_service() + api.update_warm_pool.return_value = _make_warm_pool_dto(pool=0) + result = await service.update("wp-123", pool=0) + assert isinstance(result, WarmPool) + + @pytest.mark.asyncio + async def test_delete(self): + service, api = self._make_service() + await service.delete("wp-123") + api.delete_warm_pool.assert_called_once_with("wp-123") diff --git a/sdk-ruby/lib/daytona/daytona.rb b/sdk-ruby/lib/daytona/daytona.rb index 33b9eccad..e30865930 100644 --- a/sdk-ruby/lib/daytona/daytona.rb +++ b/sdk-ruby/lib/daytona/daytona.rb @@ -25,6 +25,9 @@ class Daytona # @return [Daytona::SecretService] attr_reader :secret + # @return [Daytona::WarmPoolService] + attr_reader :warm_pool + # @return [DaytonaApiClient::ObjectStorageApi] attr_reader :object_storage_api @@ -53,6 +56,7 @@ def initialize(config = Config.new) @analytics_api_url_mutex = Mutex.new @volume = VolumeService.new(DaytonaApiClient::VolumesApi.new(api_client), otel_state:) @secret = SecretService.new(DaytonaApiClient::SecretApi.new(api_client), otel_state:) + @warm_pool = WarmPoolService.new(DaytonaApiClient::WarmPoolsApi.new(api_client), otel_state:) @object_storage_api = DaytonaApiClient::ObjectStorageApi.new(api_client) @snapshots_api = DaytonaApiClient::SnapshotsApi.new(api_client) @snapshot = SnapshotService.new( diff --git a/sdk-ruby/lib/daytona/sdk.rb b/sdk-ruby/lib/daytona/sdk.rb index e84edd062..abe3ffbb6 100644 --- a/sdk-ruby/lib/daytona/sdk.rb +++ b/sdk-ruby/lib/daytona/sdk.rb @@ -47,6 +47,8 @@ require_relative 'util' require_relative 'volume' require_relative 'volume_service' +require_relative 'warm_pool' +require_relative 'warm_pool_service' require_relative 'process' module Daytona diff --git a/sdk-ruby/lib/daytona/warm_pool.rb b/sdk-ruby/lib/daytona/warm_pool.rb new file mode 100644 index 000000000..428693938 --- /dev/null +++ b/sdk-ruby/lib/daytona/warm_pool.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Daytona + # A warm pool of ready-to-use Sandboxes for a snapshot. `current_size` versus + # `pool` is the pool's status: `current_size` is the number of ready warm + # sandboxes, `pool` is the desired number. `error_reason` is set when the + # pool cannot be filled. + class WarmPool + # @return [String] + attr_reader :id + + # @return [String] + attr_reader :organization_id + + # @return [String] + attr_reader :snapshot + + # @return [String] + attr_reader :target + + # @return [Integer] + attr_reader :pool + + # @return [Integer] + attr_reader :current_size + + # @return [Integer] + attr_reader :cpu + + # @return [Integer] + attr_reader :mem + + # @return [Integer] + attr_reader :disk + + # @return [String] + attr_reader :os_user + + # @return [Hash{String => String}] + attr_reader :env + + # @return [String, nil] + attr_reader :error_reason + + # @return [String] + attr_reader :created_at + + # @return [String] + attr_reader :updated_at + + # Initialize warm pool from DTO + # + # @param warm_pool_dto [DaytonaApiClient::WarmPool] + def initialize(warm_pool_dto) + @id = warm_pool_dto.id + @organization_id = warm_pool_dto.organization_id + @snapshot = warm_pool_dto.snapshot + @target = warm_pool_dto.target + # The generated client deserializes OpenAPI numbers as Float; these are all integral. + @pool = warm_pool_dto.pool.to_i + @current_size = warm_pool_dto.current_size.to_i + @cpu = warm_pool_dto.cpu.to_i + @mem = warm_pool_dto.mem.to_i + @disk = warm_pool_dto.disk.to_i + @os_user = warm_pool_dto.os_user + @env = warm_pool_dto.env + @error_reason = warm_pool_dto.error_reason + @created_at = warm_pool_dto.created_at + @updated_at = warm_pool_dto.updated_at + end + end +end diff --git a/sdk-ruby/lib/daytona/warm_pool_service.rb b/sdk-ruby/lib/daytona/warm_pool_service.rb new file mode 100644 index 000000000..309c51684 --- /dev/null +++ b/sdk-ruby/lib/daytona/warm_pool_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Daytona + class WarmPoolService + include Instrumentation + + # Service for managing Daytona Warm Pools. Can be used to list, create, update and delete Warm Pools. + # + # @param warm_pools_api [DaytonaApiClient::WarmPoolsApi] + # @param otel_state [Daytona::OtelState, nil] + def initialize(warm_pools_api, otel_state: nil) + @warm_pools_api = warm_pools_api + @otel_state = otel_state + end + + # Create a new Warm Pool. + # + # @param snapshot [String] The snapshot (ID or name) to keep warm sandboxes for + # @param pool [Integer] Number of warm sandboxes to keep ready + # @param target [String, nil] Target region; defaults to the organization default region + # @return [Daytona::WarmPool] + def create(snapshot, pool, target: nil) + WarmPool.new(warm_pools_api.create_warm_pool(DaytonaApiClient::CreateWarmPool.new(snapshot:, pool:, target:))) + end + + # Delete a Warm Pool. + # + # @param warm_pool_id [String] + # @return [void] + def delete(warm_pool_id) = warm_pools_api.delete_warm_pool(warm_pool_id) + + # List all Warm Pools. + # + # @return [Array] + def list + warm_pools_api.list_warm_pools.map { |warm_pool| WarmPool.new(warm_pool) } + end + + # Update the desired size of a Warm Pool. + # + # @param warm_pool_id [String] + # @param pool [Integer] New desired number of warm sandboxes (0 drains the pool) + # @return [Daytona::WarmPool] + def update(warm_pool_id, pool) + WarmPool.new(warm_pools_api.update_warm_pool(warm_pool_id, DaytonaApiClient::UpdateWarmPool.new(pool:))) + end + + instrument :create, :delete, :list, :update, component: 'WarmPoolService' + + private + + # @return [DaytonaApiClient::WarmPoolsApi] + attr_reader :warm_pools_api + + # @return [Daytona::OtelState, nil] + attr_reader :otel_state + end +end diff --git a/sdk-ruby/spec/daytona/warm_pool_service_spec.rb b/sdk-ruby/spec/daytona/warm_pool_service_spec.rb new file mode 100644 index 000000000..3fd515588 --- /dev/null +++ b/sdk-ruby/spec/daytona/warm_pool_service_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +RSpec.describe Daytona::WarmPoolService do + let(:warm_pools_api) { instance_double(DaytonaApiClient::WarmPoolsApi) } + let(:service) { described_class.new(warm_pools_api) } + + describe '#create' do + it 'creates a warm pool and returns a WarmPool model' do + dto = build_warm_pool_dto(snapshot: 'my-snapshot', pool: 5) + allow(warm_pools_api).to receive(:create_warm_pool).and_return(dto) + + warm_pool = service.create('my-snapshot', 5) + + expect(warm_pool).to be_a(Daytona::WarmPool) + expect(warm_pool.snapshot).to eq('my-snapshot') + expect(warm_pool.pool).to eq(5) + expect(warm_pools_api).to have_received(:create_warm_pool) do |request| + expect(request.snapshot).to eq('my-snapshot') + expect(request.pool).to eq(5) + expect(request.target).to be_nil + end + end + + it 'passes the target region when given' do + dto = build_warm_pool_dto(target: 'eu') + allow(warm_pools_api).to receive(:create_warm_pool).and_return(dto) + + service.create('my-snapshot', 5, target: 'eu') + + expect(warm_pools_api).to have_received(:create_warm_pool) do |request| + expect(request.target).to eq('eu') + end + end + end + + describe '#delete' do + it 'deletes a warm pool by id' do + allow(warm_pools_api).to receive(:delete_warm_pool).with('wp-123') + + service.delete('wp-123') + + expect(warm_pools_api).to have_received(:delete_warm_pool).with('wp-123') + end + end + + describe '#list' do + it 'lists warm pools as WarmPool models' do + allow(warm_pools_api).to receive(:list_warm_pools).and_return([build_warm_pool_dto]) + + warm_pools = service.list + + expect(warm_pools.length).to eq(1) + expect(warm_pools.first).to be_a(Daytona::WarmPool) + expect(warm_pools.first.current_size).to eq(3) + end + end + + describe '#update' do + it 'updates the desired pool size' do + dto = build_warm_pool_dto(pool: 10) + allow(warm_pools_api).to receive(:update_warm_pool).and_return(dto) + + warm_pool = service.update('wp-123', 10) + + expect(warm_pool).to be_a(Daytona::WarmPool) + expect(warm_pool.pool).to eq(10) + expect(warm_pools_api).to have_received(:update_warm_pool) do |id, request| + expect(id).to eq('wp-123') + expect(request.pool).to eq(10) + end + end + end +end diff --git a/sdk-ruby/spec/spec_helper.rb b/sdk-ruby/spec/spec_helper.rb index d2df17286..8efc7be3a 100644 --- a/sdk-ruby/spec/spec_helper.rb +++ b/sdk-ruby/spec/spec_helper.rb @@ -130,6 +130,27 @@ def build_secret_dto(overrides = {}) instance_double(DaytonaApiClient::Secret, **attrs) end +def build_warm_pool_dto(overrides = {}) + attrs = { + id: 'wp-123', + organization_id: 'org-1', + snapshot: 'test-snapshot', + target: 'us', + pool: 5, + current_size: 3, + cpu: 2, + mem: 4, + disk: 10, + os_user: 'daytona', + env: {}, + error_reason: nil, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z' + }.merge(overrides) + + instance_double(DaytonaApiClient::WarmPool, **attrs) +end + def build_snapshot_dto(overrides = {}) attrs = { id: 'snap-123', diff --git a/sdk-typescript/src/Daytona.ts b/sdk-typescript/src/Daytona.ts index 3df5e1c16..838cfd0b6 100644 --- a/sdk-typescript/src/Daytona.ts +++ b/sdk-typescript/src/Daytona.ts @@ -13,6 +13,7 @@ import { SandboxState, SecretApi, VolumesApi, + WarmPoolsApi, ConfigApi, } from '@daytona/api-client' import type { GpuType, SandboxVolume } from '@daytona/api-client' @@ -31,6 +32,7 @@ import type { ListSandboxesQuery } from './Sandbox' import { SecretService } from './Secret' import { SnapshotService } from './Snapshot' import { VolumeService } from './Volume' +import { WarmPoolService } from './WarmPool' import { getPackageInfo, dynamicRequire } from './utils/Import' import { EventDispatcher } from './utils/EventDispatcher' import { EventSubscriptionManager } from './utils/EventSubscriptionManager' @@ -227,6 +229,7 @@ export type CreateSandboxFromSnapshotParams = CreateSandboxBaseParams & { * @property {VolumeService} volume - Service for managing Daytona Volumes * @property {SnapshotService} snapshot - Service for managing Daytona Snapshots * @property {SecretService} secret - Service for managing Daytona Secrets + * @property {WarmPoolService} warmPool - Service for managing Daytona Warm Pools * * @example * // Using environment variables @@ -283,6 +286,7 @@ export class Daytona implements AsyncDisposable { public readonly volume: VolumeService public readonly snapshot: SnapshotService public readonly secret: SecretService + public readonly warmPool: WarmPoolService /** * Creates a new Daytona client instance. @@ -372,6 +376,7 @@ export class Daytona implements AsyncDisposable { this.configApi = new ConfigApi(configuration, '', axiosInstance) this.volume = new VolumeService(new VolumesApi(configuration, '', axiosInstance)) this.secret = new SecretService(new SecretApi(configuration, '', axiosInstance)) + this.warmPool = new WarmPoolService(new WarmPoolsApi(configuration, '', axiosInstance)) this.snapshot = new SnapshotService( configuration, new SnapshotsApi(configuration, '', axiosInstance), diff --git a/sdk-typescript/src/WarmPool.ts b/sdk-typescript/src/WarmPool.ts new file mode 100644 index 000000000..5edb0a649 --- /dev/null +++ b/sdk-typescript/src/WarmPool.ts @@ -0,0 +1,106 @@ +import { WarmPoolsApi } from '@daytona/api-client' +import type { CreateWarmPool, UpdateWarmPool, WarmPool as WarmPoolDto } from '@daytona/api-client' +import { WithInstrumentation } from './utils/otel.decorator' + +/** + * Represents a Daytona Warm Pool which keeps ready-to-use Sandboxes for a snapshot. + * + * `currentSize` versus `pool` is the pool's status: `currentSize` is the number of ready + * warm sandboxes, `pool` is the desired number. `errorReason` is set when the pool cannot + * be filled. + * + * @property {string} id - Unique identifier for the Warm Pool + * @property {string} organizationId - Organization ID that owns the Warm Pool + * @property {string} snapshot - Snapshot the pool keeps warm sandboxes for + * @property {string} target - Target region of the pool + * @property {number} pool - Desired number of warm sandboxes + * @property {number} currentSize - Current number of ready warm sandboxes in the pool + * @property {number} cpu - CPU cores per sandbox + * @property {number} mem - Memory per sandbox in GiB + * @property {number} disk - Disk per sandbox in GiB + * @property {string} osUser - OS user of the warm sandboxes + * @property {Record} env - Environment variables of the warm sandboxes + * @property {string | null} [errorReason] - Reason the pool cannot be filled, if any + * @property {string} createdAt - Date and time when the Warm Pool was created + * @property {string} updatedAt - Date and time when the Warm Pool was last updated + */ +export type WarmPool = WarmPoolDto & { __brand: 'WarmPool' } + +/** + * Service for managing Daytona Warm Pools. + * + * This service provides methods to list, create, update, and delete Warm Pools. + * + * @class + */ +export class WarmPoolService { + constructor(private warmPoolsApi: WarmPoolsApi) {} + + /** + * Lists all Warm Pools in the organization. + * + * @returns {Promise} List of all Warm Pools + * + * @example + * const daytona = new Daytona(); + * const pools = await daytona.warmPool.list(); + * pools.forEach(pool => console.log(`${pool.snapshot}: ${pool.currentSize}/${pool.pool} ready`)); + */ + @WithInstrumentation() + async list(): Promise { + const response = await this.warmPoolsApi.listWarmPools() + return response.data as WarmPool[] + } + + /** + * Creates a new Warm Pool. + * + * @param {CreateWarmPool} params - Parameters for the new Warm Pool + * @returns {Promise} The newly created Warm Pool + * @throws {DaytonaConflictError} If a Warm Pool for the same snapshot and region already exists + * + * @example + * const daytona = new Daytona(); + * const pool = await daytona.warmPool.create({ snapshot: 'my-snapshot', pool: 5 }); + * console.log(`Created warm pool ${pool.id} in ${pool.target}`); + */ + @WithInstrumentation() + async create(params: CreateWarmPool): Promise { + const response = await this.warmPoolsApi.createWarmPool(params) + return response.data as WarmPool + } + + /** + * Updates the desired size of a Warm Pool. + * + * @param {string} warmPoolId - ID of the Warm Pool to update + * @param {UpdateWarmPool} params - Fields to update (`pool: 0` drains the pool) + * @returns {Promise} The updated Warm Pool + * @throws {DaytonaNotFoundError} If the Warm Pool does not exist + * + * @example + * const daytona = new Daytona(); + * const pool = await daytona.warmPool.update("warm-pool-id", { pool: 10 }); + */ + @WithInstrumentation() + async update(warmPoolId: string, params: UpdateWarmPool): Promise { + const response = await this.warmPoolsApi.updateWarmPool(warmPoolId, params) + return response.data as WarmPool + } + + /** + * Deletes a Warm Pool. + * + * @param {string} warmPoolId - ID of the Warm Pool to delete + * @returns {Promise} + * @throws {DaytonaNotFoundError} If the Warm Pool does not exist + * + * @example + * const daytona = new Daytona(); + * await daytona.warmPool.delete("warm-pool-id"); + */ + @WithInstrumentation() + async delete(warmPoolId: string): Promise { + await this.warmPoolsApi.deleteWarmPool(warmPoolId) + } +} diff --git a/sdk-typescript/src/__tests__/Daytona.test.ts b/sdk-typescript/src/__tests__/Daytona.test.ts index 1ac3da63f..daac4774f 100644 --- a/sdk-typescript/src/__tests__/Daytona.test.ts +++ b/sdk-typescript/src/__tests__/Daytona.test.ts @@ -52,6 +52,7 @@ jest.mock( ConfigApi: jest.fn(() => mockConfigApi), VolumesApi: jest.fn(() => mockVolumesApi), SecretApi: jest.fn(() => mockSecretApi), + WarmPoolsApi: jest.fn(() => ({})), SandboxState: { PENDING_BUILD: 'pending_build', STARTED: 'started', diff --git a/sdk-typescript/src/__tests__/WarmPool.test.ts b/sdk-typescript/src/__tests__/WarmPool.test.ts new file mode 100644 index 000000000..83dc86371 --- /dev/null +++ b/sdk-typescript/src/__tests__/WarmPool.test.ts @@ -0,0 +1,54 @@ +import { createApiResponse } from './helpers' +import { WarmPoolService } from '../WarmPool' + +jest.mock('@daytona/api-client', () => ({}), { virtual: true }) + +describe('WarmPoolService', () => { + const warmPoolsApi = { + listWarmPools: jest.fn(), + createWarmPool: jest.fn(), + updateWarmPool: jest.fn(), + deleteWarmPool: jest.fn(), + } + const service = new WarmPoolService(warmPoolsApi as unknown as never) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('lists warm pools', async () => { + warmPoolsApi.listWarmPools.mockResolvedValue(createApiResponse([{ id: 'wp1', snapshot: 'snap', pool: 3 }])) + + await expect(service.list()).resolves.toEqual([{ id: 'wp1', snapshot: 'snap', pool: 3 }]) + }) + + it('creates a warm pool with the given params', async () => { + warmPoolsApi.createWarmPool.mockResolvedValue(createApiResponse({ id: 'wp2', snapshot: 'snap', pool: 5 })) + + await expect(service.create({ snapshot: 'snap', pool: 5 })).resolves.toEqual({ + id: 'wp2', + snapshot: 'snap', + pool: 5, + }) + expect(warmPoolsApi.createWarmPool).toHaveBeenCalledWith({ snapshot: 'snap', pool: 5 }) + }) + + it('updates the pool size', async () => { + warmPoolsApi.updateWarmPool.mockResolvedValue(createApiResponse({ id: 'wp3', pool: 10 })) + + await expect(service.update('wp3', { pool: 10 })).resolves.toEqual({ id: 'wp3', pool: 10 }) + expect(warmPoolsApi.updateWarmPool).toHaveBeenCalledWith('wp3', { pool: 10 }) + }) + + it('deletes a warm pool', async () => { + await service.delete('wp4') + expect(warmPoolsApi.deleteWarmPool).toHaveBeenCalledWith('wp4') + }) + + it('propagates create failures', async () => { + const error = new Error('conflict') + warmPoolsApi.createWarmPool.mockRejectedValue(error) + + await expect(service.create({ snapshot: 'snap', pool: 1 })).rejects.toBe(error) + }) +}) diff --git a/sdk-typescript/src/index.ts b/sdk-typescript/src/index.ts index 832f7e742..7fc1762a9 100644 --- a/sdk-typescript/src/index.ts +++ b/sdk-typescript/src/index.ts @@ -80,6 +80,7 @@ export { Sandbox } from './Sandbox' export type { ListSandboxesQuery, SandboxMetrics } from './Sandbox' export type { Secret, CreateSecretParams, UpdateSecretParams, ListSecretsQuery, ListSecretsResponse } from './Secret' export type { CreateSnapshotParams } from './Snapshot' +export type { WarmPool } from './WarmPool' export { ComputerUse, Mouse, Keyboard, Screenshot, Display, Accessibility } from './ComputerUse' export type { BarChart, @@ -109,6 +110,7 @@ export { ListSecretsPaginatedSortEnum, ListSecretsPaginatedOrderEnum, } from '@daytona/api-client' +export type { CreateWarmPool, UpdateWarmPool } from '@daytona/api-client' export type { FileInfo, GitStatus,