diff --git a/v1/providers/shadeform/instancetype.go b/v1/providers/shadeform/instancetype.go index 06b0701..842a1f6 100644 --- a/v1/providers/shadeform/instancetype.go +++ b/v1/providers/shadeform/instancetype.go @@ -17,6 +17,8 @@ import ( const ( UsdCurrentCode = "USD" AllRegions = "all" + rentalTypeSpot = "spot" + rentalTypeKey = "rental_type" ) // TODO: We need to apply a filter to specifically limit the integration and api to selected clouds and shade instance types @@ -218,6 +220,11 @@ func (c *ShadeformClient) convertShadeformInstanceTypeToV1InstanceType(shadeform estimatedDeployTime := c.getEstimatedDeployTime(shadeformInstanceType) for _, region := range shadeformInstanceType.Availability { + // Shadeform lists a region once per rental type; skip spot so it never marks a + // SKU available. Untagged entries are treated as on-demand. + if isSpotAvailability(region) { + continue + } instanceTypes = append(instanceTypes, v1.InstanceType{ ID: v1.InstanceTypeID(c.getInstanceTypeID(instanceType, region.Region)), Type: instanceType, @@ -257,6 +264,14 @@ func (c *ShadeformClient) convertShadeformInstanceTypeToV1InstanceType(shadeform return instanceTypes, nil } +func isSpotAvailability(availability openapi.Availability) bool { + rentalType, ok := availability.AdditionalProperties[rentalTypeKey].(string) + if !ok { + return false + } + return strings.EqualFold(rentalType, rentalTypeSpot) +} + func convertHourlyPriceToAmount(hourlyPrice int32) (*currency.Amount, error) { number := fmt.Sprintf("%.2f", float64(hourlyPrice)/100) diff --git a/v1/providers/shadeform/instancetype_test.go b/v1/providers/shadeform/instancetype_test.go index 27e074b..fe2d593 100644 --- a/v1/providers/shadeform/instancetype_test.go +++ b/v1/providers/shadeform/instancetype_test.go @@ -1,12 +1,15 @@ package v1 import ( + "encoding/json" + "fmt" "testing" "time" v1 "github.com/brevdev/cloud/v1" openapi "github.com/brevdev/cloud/v1/providers/shadeform/gen/shadeform" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsSelectedByArgs(t *testing.T) { @@ -98,6 +101,96 @@ func TestIsSelectedByArgs(t *testing.T) { } } +func TestConvertShadeformInstanceTypeToV1InstanceTypeRentalType(t *testing.T) { + t.Parallel() + + client := &ShadeformClient{} + + // Built from JSON (not a struct literal) so rental_type lands in + // Availability.AdditionalProperties - the path isSpotAvailability reads. If the client + // is regenerated with a typed rental_type field, the spot cases below will fail. + newInstanceType := func(t *testing.T, availabilityJSON string) openapi.InstanceType { + t.Helper() + raw := fmt.Sprintf(`{ + "cloud": "excesssupply", + "shade_instance_type": "B300_sxm6x8", + "cloud_instance_type": "8x-b300-sxm6-ac", + "hourly_price": 3600, + "deployment_type": "vm", + "configuration": { + "memory_in_gb": 2790, + "storage_in_gb": 27997, + "vcpus": 120, + "num_gpus": 8, + "gpu_type": "B300", + "interconnect": "sxm6", + "vram_per_gpu_in_gb": 288, + "os_options": ["ubuntu24.04_cuda13.0_shade_os"], + "gpu_manufacturer": "nvidia" + }, + "availability": %s + }`, availabilityJSON) + + var it openapi.InstanceType + require.NoError(t, json.Unmarshal([]byte(raw), &it)) + return it + } + + cases := []struct { + name string + availabilityJSON string + wantLen int + wantIsAvailable bool // only checked when wantLen == 1 + wantLocation string + }{ + { + name: "spot availability does not make instance type bookable", + availabilityJSON: `[ + {"region": "us-west-1", "available": false, "display_name": "us-west-1", "rental_type": "on_demand"}, + {"region": "us-west-1", "available": true, "display_name": "us-west-1", "rental_type": "spot", "hourly_price": "36"} + ]`, + wantLen: 1, + wantIsAvailable: false, + wantLocation: "us-west-1", + }, + { + name: "on_demand availability is preserved", + availabilityJSON: `[{"region": "warsaw-poland-1", "available": true, "display_name": "PL, Warsaw", "rental_type": "on_demand"}]`, + wantLen: 1, + wantIsAvailable: true, + wantLocation: "warsaw-poland-1", + }, + { + name: "missing rental type is treated as on_demand", + availabilityJSON: `[{"region": "paris-france-1", "available": true, "display_name": "FR, Paris"}]`, + wantLen: 1, + wantIsAvailable: true, + wantLocation: "paris-france-1", + }, + { + name: "spot-only region is dropped", + availabilityJSON: `[{"region": "us-west-1", "available": true, "display_name": "us-west-1", "rental_type": "spot", "hourly_price": "36"}]`, + wantLen: 0, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := client.convertShadeformInstanceTypeToV1InstanceType(newInstanceType(t, tt.availabilityJSON)) + assert.NoError(t, err) + assert.Len(t, got, tt.wantLen) + if tt.wantLen == 1 { + assert.Equal(t, tt.wantIsAvailable, got[0].IsAvailable) + assert.Equal(t, tt.wantLocation, got[0].Location) + assert.Equal(t, "excesssupply_B300_sxm6x8", got[0].Type) + assert.Equal(t, CloudProviderID, got[0].Cloud) + } + }) + } +} + func TestGetEstimatedDeployTime(t *testing.T) { t.Parallel()