Skip to content

Commit 066fc51

Browse files
fix: deduplicate catalog fetches and normalize --region case
1 parent b9e1dd3 commit 066fc51

2 files changed

Lines changed: 114 additions & 66 deletions

File tree

pkg/cmd/gpucreate/gpucreate.go

Lines changed: 62 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra
181181
name = args[0]
182182
}
183183

184+
// Normalize --region: provider region names are case-sensitive on the
185+
// server side (e.g. GCP rejects "US-WEST1", accepts "us-west1"), while
186+
// our client-side validation is case-insensitive. Lowercase here so
187+
// what we validate matches what we send.
188+
filters.region = strings.ToLower(strings.TrimSpace(filters.region))
189+
184190
launchableID, err := parseLaunchableID(launchable)
185191
if err != nil {
186192
return err
@@ -237,25 +243,41 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra
237243
Region: filters.region,
238244
}
239245

246+
// Fetch the instance-type catalog at most once per invocation. Region
247+
// validation, auto-search, and dry-run preview all share the same response
248+
// so we never issue more than one round-trip. --type explicit + no region
249+
// skips the fetch entirely (runDryRun returns early on explicit specs).
250+
var catalogItems []gpusearch.InstanceType
251+
needCatalog := filters.region != "" || (len(types) == 0 && launchableID == "")
252+
if needCatalog {
253+
resp, err := gpuCreateStore.GetInstanceTypes(false)
254+
if err != nil {
255+
return breverrors.WrapAndTrace(err)
256+
}
257+
if resp != nil {
258+
catalogItems = resp.Items
259+
}
260+
}
261+
240262
if filters.region != "" {
241-
if err := validateRegionExists(filters.region, gpuCreateStore); err != nil {
263+
if err := validateRegionExists(filters.region, catalogItems); err != nil {
242264
return err
243265
}
244266
}
245267

246-
opts.InstanceTypes, err = resolveInstanceTypes(cmd, gpuCreateStore, opts, types, &filters)
268+
opts.InstanceTypes, err = resolveInstanceTypes(cmd, catalogItems, opts, types, &filters)
247269
if err != nil {
248270
return err
249271
}
250272

251273
if filters.region != "" {
252-
if err := validateRegion(filters.region, opts.InstanceTypes, gpuCreateStore); err != nil {
274+
if err := validateRegion(filters.region, opts.InstanceTypes, catalogItems); err != nil {
253275
return err
254276
}
255277
}
256278

257279
if dryRun {
258-
return runDryRun(t, gpuCreateStore, opts.InstanceTypes, &filters)
280+
return runDryRun(t, catalogItems, opts.InstanceTypes, &filters)
259281
}
260282

261283
return RunGPUCreate(t, gpuCreateStore, opts)
@@ -463,8 +485,9 @@ func launchableBuildModeName(info *store.LaunchableResponse) string {
463485
}
464486
}
465487

466-
// resolveInstanceTypes determines instance types from launchable, flags, or filters
467-
func resolveInstanceTypes(cmd *cobra.Command, gpuCreateStore GPUCreateStore, opts GPUCreateOptions, types []InstanceSpec, filters *searchFilterFlags) ([]InstanceSpec, error) {
488+
// resolveInstanceTypes determines instance types from launchable, flags, or filters.
489+
// Operates on a pre-fetched catalog when auto-search is needed.
490+
func resolveInstanceTypes(cmd *cobra.Command, items []gpusearch.InstanceType, opts GPUCreateOptions, types []InstanceSpec, filters *searchFilterFlags) ([]InstanceSpec, error) {
468491
if opts.LaunchableID != "" && len(types) == 0 && !cmd.Flags().Changed("type") {
469492
instanceType := ""
470493
if opts.LaunchableInfo != nil {
@@ -477,10 +500,7 @@ func resolveInstanceTypes(cmd *cobra.Command, gpuCreateStore GPUCreateStore, opt
477500
}
478501

479502
if len(types) == 0 {
480-
filtered, err := getFilteredInstanceTypes(gpuCreateStore, filters)
481-
if err != nil {
482-
return nil, breverrors.WrapAndTrace(err)
483-
}
503+
filtered := getFilteredInstanceTypes(items, filters)
484504
if len(filtered) == 0 {
485505
return nil, breverrors.NewValidationError("no GPU instances match the specified filters. Try 'brev search' to see available options")
486506
}
@@ -511,15 +531,11 @@ func parseStartupScript(value string) (string, error) {
511531
return value, nil
512532
}
513533

514-
// searchInstances fetches and filters GPU instances using user-provided filters merged with defaults
515-
func searchInstances(s GPUCreateStore, filters *searchFilterFlags) ([]gpusearch.GPUInstanceInfo, float64, error) {
516-
response, err := s.GetInstanceTypes(false)
517-
if err != nil {
518-
return nil, 0, breverrors.WrapAndTrace(err)
519-
}
520-
521-
if response == nil || len(response.Items) == 0 {
522-
return nil, 0, nil
534+
// searchInstances filters GPU instances using user-provided filters merged with defaults.
535+
// Operates on a pre-fetched catalog so callers can share one network round-trip.
536+
func searchInstances(items []gpusearch.InstanceType, filters *searchFilterFlags) ([]gpusearch.GPUInstanceInfo, float64) {
537+
if len(items) == 0 {
538+
return nil, 0
523539
}
524540

525541
minTotalVRAM := orDefault(filters.minTotalVRAM, defaultMinTotalVRAM)
@@ -534,21 +550,19 @@ func searchInstances(s GPUCreateStore, filters *searchFilterFlags) ([]gpusearch.
534550
sortBy = "price"
535551
}
536552

537-
instances := gpusearch.ProcessInstances(response.Items)
553+
instances := gpusearch.ProcessInstances(items)
538554
filtered := gpusearch.FilterInstances(instances, filters.gpuName, filters.region, filters.provider, "", filters.minVRAM,
539555
minTotalVRAM, minCapability, 0, minDisk, 0, maxBootTime, filters.stoppable, filters.rebootable, filters.flexPorts, true)
540556
gpusearch.SortInstances(filtered, sortBy, filters.descending)
541557

542-
return filtered, minDisk, nil
558+
return filtered, minDisk
543559
}
544560

545-
// getFilteredInstanceTypes fetches GPU instance types using user-provided filters
561+
// getFilteredInstanceTypes filters GPU instance types using user-provided filters
546562
// merged with defaults. When a filter flag is not set, the default value is used.
547-
func getFilteredInstanceTypes(s GPUCreateStore, filters *searchFilterFlags) ([]InstanceSpec, error) {
548-
filtered, minDisk, err := searchInstances(s, filters)
549-
if err != nil {
550-
return nil, breverrors.WrapAndTrace(err)
551-
}
563+
// Operates on a pre-fetched catalog.
564+
func getFilteredInstanceTypes(items []gpusearch.InstanceType, filters *searchFilterFlags) []InstanceSpec {
565+
filtered, minDisk := searchInstances(items, filters)
552566

553567
var specs []InstanceSpec
554568
for _, inst := range filtered {
@@ -559,20 +573,18 @@ func getFilteredInstanceTypes(s GPUCreateStore, filters *searchFilterFlags) ([]I
559573
specs = append(specs, InstanceSpec{Type: inst.Type, DiskGB: diskGB})
560574
}
561575

562-
return specs, nil
576+
return specs
563577
}
564578

565-
// runDryRun shows the instance types that would be used without creating anything
566-
func runDryRun(t *terminal.Terminal, s GPUCreateStore, specs []InstanceSpec, filters *searchFilterFlags) error {
579+
// runDryRun shows the instance types that would be used without creating anything.
580+
// Operates on a pre-fetched catalog.
581+
func runDryRun(t *terminal.Terminal, items []gpusearch.InstanceType, specs []InstanceSpec, filters *searchFilterFlags) error {
567582
if len(specs) > 0 {
568583
t.Print(formatInstanceSpecs(specs))
569584
return nil
570585
}
571586

572-
filtered, _, err := searchInstances(s, filters)
573-
if err != nil {
574-
return breverrors.WrapAndTrace(err)
575-
}
587+
filtered, _ := searchInstances(items, filters)
576588

577589
piped := gpusearch.IsStdoutPiped()
578590
if err := gpusearch.DisplayGPUResults(t, filtered, false, piped, false); err != nil {
@@ -582,18 +594,14 @@ func runDryRun(t *terminal.Terminal, s GPUCreateStore, specs []InstanceSpec, fil
582594
}
583595

584596
// validateRegionExists checks that the given region appears in at least one instance type in the catalog.
585-
func validateRegionExists(region string, store GPUCreateStore) error {
586-
response, err := store.GetInstanceTypes(false)
587-
if err != nil {
588-
return breverrors.WrapAndTrace(err)
589-
}
590-
if response == nil || len(response.Items) == 0 {
597+
// Operates on a pre-fetched catalog.
598+
func validateRegionExists(region string, items []gpusearch.InstanceType) error {
599+
if len(items) == 0 {
591600
return nil
592601
}
593602

594-
regionLower := strings.ToLower(region)
595-
for _, item := range response.Items {
596-
if typeSupportsRegion(item, regionLower) {
603+
for _, item := range items {
604+
if typeSupportsRegion(item, region) {
597605
return nil
598606
}
599607
}
@@ -605,25 +613,17 @@ func validateRegionExists(region string, store GPUCreateStore) error {
605613
}
606614

607615
// validateRegion checks that every requested instance type is available in the given region.
608-
func validateRegion(region string, types []InstanceSpec, store GPUCreateStore) error {
609-
if len(types) == 0 {
610-
return nil
611-
}
612-
613-
response, err := store.GetInstanceTypes(false)
614-
if err != nil {
615-
return breverrors.WrapAndTrace(err)
616-
}
617-
if response == nil || len(response.Items) == 0 {
616+
// Operates on a pre-fetched catalog.
617+
func validateRegion(region string, types []InstanceSpec, items []gpusearch.InstanceType) error {
618+
if len(types) == 0 || len(items) == 0 {
618619
return nil
619620
}
620621

621-
catalog := make(map[string]gpusearch.InstanceType, len(response.Items))
622-
for _, item := range response.Items {
622+
catalog := make(map[string]gpusearch.InstanceType, len(items))
623+
for _, item := range items {
623624
catalog[item.Type] = item
624625
}
625626

626-
regionLower := strings.ToLower(region)
627627
var unsupported []string
628628
var unknown []string
629629

@@ -633,7 +633,7 @@ func validateRegion(region string, types []InstanceSpec, store GPUCreateStore) e
633633
unknown = append(unknown, spec.Type)
634634
continue
635635
}
636-
if !typeSupportsRegion(item, regionLower) {
636+
if !typeSupportsRegion(item, region) {
637637
unsupported = append(unsupported, spec.Type)
638638
}
639639
}
@@ -653,10 +653,12 @@ func validateRegion(region string, types []InstanceSpec, store GPUCreateStore) e
653653
}
654654

655655
// typeSupportsRegion reports whether an instance type lists the given region
656-
// (already lowercased) in its AvailableLocations, using substring matching.
657-
func typeSupportsRegion(item gpusearch.InstanceType, regionLower string) bool {
656+
// in its AvailableLocations. Match is case-insensitive but must be an exact
657+
// equality — substring matching would let e.g. --region "us" pass validation
658+
// against "us-east-1", and then we'd forward "us" to the server as the region.
659+
func typeSupportsRegion(item gpusearch.InstanceType, region string) bool {
658660
for _, loc := range item.AvailableLocations {
659-
if strings.Contains(strings.ToLower(loc), regionLower) {
661+
if strings.EqualFold(loc, region) {
660662
return true
661663
}
662664
}

pkg/cmd/gpucreate/gpucreate_test.go

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type MockGPUCreateStore struct {
2727
CreatedWorkspaces []*entity.Workspace
2828
DeletedWorkspaceIDs []string
2929
FetchedLifeCycleScriptIDs []string
30+
GetInstanceTypesCallCount int
3031
}
3132

3233
func NewMockGPUCreateStore() *MockGPUCreateStore {
@@ -127,6 +128,7 @@ func (m *MockGPUCreateStore) RedeemCouponCode(organizationID string, code string
127128
}
128129

129130
func (m *MockGPUCreateStore) GetInstanceTypes(_ bool) (*gpusearch.InstanceTypesResponse, error) {
131+
m.GetInstanceTypesCallCount++
130132
// Return a default set of instance types for testing
131133
return &gpusearch.InstanceTypesResponse{
132134
Items: []gpusearch.InstanceType{
@@ -776,37 +778,81 @@ func TestCreateDryRunWithExplicitTypesDoesNotProvision(t *testing.T) {
776778
assert.Empty(t, mock.CreatedWorkspaces)
777779
}
778780

781+
func TestCreateFetchesCatalogAtMostOnce(t *testing.T) {
782+
tests := []struct {
783+
name string
784+
args []string
785+
wantFetchCount int
786+
}{
787+
{
788+
name: "explicit type, no region — no catalog fetch needed",
789+
args: []string{"no-fetch", "--type", "g5.xlarge", "--dry-run"},
790+
wantFetchCount: 0,
791+
},
792+
{
793+
name: "auto-search needs catalog once",
794+
args: []string{"auto", "--dry-run"},
795+
wantFetchCount: 1,
796+
},
797+
{
798+
name: "region triggers catalog fetch, reused across validations",
799+
args: []string{"reg", "--type", "g5.xlarge", "--region", "us-east-1", "--dry-run"},
800+
wantFetchCount: 1,
801+
},
802+
{
803+
name: "region + auto-search shares one fetch across validators and resolveInstanceTypes",
804+
args: []string{"reg-auto", "--region", "us-east-1", "--dry-run"},
805+
wantFetchCount: 1,
806+
},
807+
}
808+
for _, tt := range tests {
809+
t.Run(tt.name, func(t *testing.T) {
810+
mock := NewMockGPUCreateStore()
811+
term := terminal.New()
812+
813+
cmd := NewCmdGPUCreate(term, mock)
814+
cmd.SetArgs(tt.args)
815+
_ = cmd.Execute() // success not required — count assertion is the point
816+
assert.Equal(t, tt.wantFetchCount, mock.GetInstanceTypesCallCount,
817+
"GetInstanceTypes should be called %d time(s) for args %v", tt.wantFetchCount, tt.args)
818+
})
819+
}
820+
}
821+
779822
func TestGetFilteredInstanceTypesDefaults(t *testing.T) {
780823
mock := NewMockGPUCreateStore()
824+
resp, err := mock.GetInstanceTypes(false)
825+
assert.NoError(t, err)
781826

782827
// Get instance types with no user filters (uses defaults):
783828
// - 24GB VRAM (>= 20GB total VRAM requirement)
784829
// - 500GB disk (>= 500GB requirement)
785830
// - A10G GPU = 8.6 capability (>= 8.0 requirement)
786831
// - 5m boot time (< 7m requirement)
787-
specs, err := getFilteredInstanceTypes(mock, &searchFilterFlags{})
788-
assert.NoError(t, err)
832+
specs := getFilteredInstanceTypes(resp.Items, &searchFilterFlags{})
789833
assert.Len(t, specs, 1)
790834
assert.Equal(t, "g5.xlarge", specs[0].Type)
791835
assert.Equal(t, 500.0, specs[0].DiskGB) // Should use the instance's disk size
792836
}
793837

794838
func TestGetFilteredInstanceTypesWithGPUName(t *testing.T) {
795839
mock := NewMockGPUCreateStore()
840+
resp, err := mock.GetInstanceTypes(false)
841+
assert.NoError(t, err)
796842

797843
// Filter by GPU name that matches the mock data
798-
specs, err := getFilteredInstanceTypes(mock, &searchFilterFlags{gpuName: "A10G"})
799-
assert.NoError(t, err)
844+
specs := getFilteredInstanceTypes(resp.Items, &searchFilterFlags{gpuName: "A10G"})
800845
assert.Len(t, specs, 1)
801846
assert.Equal(t, "g5.xlarge", specs[0].Type)
802847
}
803848

804849
func TestGetFilteredInstanceTypesNoMatch(t *testing.T) {
805850
mock := NewMockGPUCreateStore()
851+
resp, err := mock.GetInstanceTypes(false)
852+
assert.NoError(t, err)
806853

807854
// Filter by GPU name that doesn't match
808-
specs, err := getFilteredInstanceTypes(mock, &searchFilterFlags{gpuName: "H100"})
809-
assert.NoError(t, err)
855+
specs := getFilteredInstanceTypes(resp.Items, &searchFilterFlags{gpuName: "H100"})
810856
assert.Len(t, specs, 0)
811857
}
812858

0 commit comments

Comments
 (0)