From e90f76aa26e4be474638a30e4d6acd7df3cb5672 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Mon, 23 Feb 2026 23:11:18 -0800 Subject: [PATCH 1/3] feat(search): add cpu subcommand and wide mode to brev search Restructure `brev search` into gpu/cpu subcommands: - `brev search` / `brev search gpu` - GPU instances (default, backwards compatible) - `brev search gpu --wide` - GPU instances with RAM and ARCH columns - `brev search cpu` - CPU-only instances via ?include_cpu=true API param CPU search has dedicated columns (TYPE, PROVIDER, VCPUs, RAM, ARCH, DISK, $/GB/MO, BOOT, FEATURES, $/HR) and filters (--min-ram, --arch). Shared filters (--provider, --min-vcpu, --min-disk, --max-boot-time, --stoppable, --rebootable, --flex-ports, --sort) work across both modes. Piping into `brev create` works for both GPU and CPU table output. --- pkg/cmd/gpucreate/gpucreate.go | 6 +- pkg/cmd/gpucreate/gpucreate_test.go | 24 +- pkg/cmd/gpusearch/gpusearch.go | 579 ++++++++++++++++++++++------ pkg/cmd/gpusearch/gpusearch_test.go | 75 +++- pkg/store/instancetypes.go | 19 +- 5 files changed, 559 insertions(+), 144 deletions(-) diff --git a/pkg/cmd/gpucreate/gpucreate.go b/pkg/cmd/gpucreate/gpucreate.go index 3f80a994..86f7ca1e 100644 --- a/pkg/cmd/gpucreate/gpucreate.go +++ b/pkg/cmd/gpucreate/gpucreate.go @@ -292,7 +292,7 @@ func parseStartupScript(value string) (string, error) { // searchInstances fetches and filters GPU instances using user-provided filters merged with defaults func searchInstances(s GPUCreateStore, filters *searchFilterFlags) ([]gpusearch.GPUInstanceInfo, float64, error) { - response, err := s.GetInstanceTypes() + response, err := s.GetInstanceTypes(false) if err != nil { return nil, 0, breverrors.WrapAndTrace(err) } @@ -315,7 +315,7 @@ func searchInstances(s GPUCreateStore, filters *searchFilterFlags) ([]gpusearch. instances := gpusearch.ProcessInstances(response.Items) filtered := gpusearch.FilterInstances(instances, filters.gpuName, filters.provider, filters.minVRAM, - minTotalVRAM, minCapability, minDisk, maxBootTime, filters.stoppable, filters.rebootable, filters.flexPorts) + minTotalVRAM, minCapability, minDisk, 0, maxBootTime, filters.stoppable, filters.rebootable, filters.flexPorts, true) gpusearch.SortInstances(filtered, sortBy, filters.descending) return filtered, minDisk, nil @@ -349,7 +349,7 @@ func runDryRun(t *terminal.Terminal, s GPUCreateStore, filters *searchFilterFlag } piped := gpusearch.IsStdoutPiped() - if err := gpusearch.DisplayResults(t, filtered, false, piped); err != nil { + if err := gpusearch.DisplayGPUResults(t, filtered, false, piped, false); err != nil { return breverrors.WrapAndTrace(err) } return nil diff --git a/pkg/cmd/gpucreate/gpucreate_test.go b/pkg/cmd/gpucreate/gpucreate_test.go index 6e6817d9..76b8932a 100644 --- a/pkg/cmd/gpucreate/gpucreate_test.go +++ b/pkg/cmd/gpucreate/gpucreate_test.go @@ -97,7 +97,7 @@ func (m *MockGPUCreateStore) GetAllInstanceTypesWithWorkspaceGroups(orgID string return nil, nil } -func (m *MockGPUCreateStore) GetInstanceTypes() (*gpusearch.InstanceTypesResponse, error) { +func (m *MockGPUCreateStore) GetInstanceTypes(_ bool) (*gpusearch.InstanceTypesResponse, error) { // Return a default set of instance types for testing return &gpusearch.InstanceTypesResponse{ Items: []gpusearch.InstanceType{ @@ -355,6 +355,28 @@ func TestParseTableInput(t *testing.T) { assert.Equal(t, 1000.0, specs[2].DiskGB) } +func TestParseTableInputCPU(t *testing.T) { + // Simulated plain table output from `brev search cpu` + tableInput := strings.Join([]string{ + " TYPE TARGET_DISK PROVIDER VCPUS RAM ARCH DISK $/GB/MO BOOT FEATURES $/HR", + " n2d-highcpu-2 10 gcp 2 2 x86_64 10GB-16TB $0.13 7m SP $0.05", + " n1-standard-1 10 gcp 1 4 x86_64 10GB-16TB $0.14 7m SP $0.06", + " m8i-flex.8xlarge 500 aws 32 128 x86_64 10GB-16TB $0.10 7m SRP $1.93", + "", + "Found 3 CPU instance types", + }, "\n") + + specs := parseTableInput(tableInput) + + assert.Len(t, specs, 3) + assert.Equal(t, "n2d-highcpu-2", specs[0].Type) + assert.Equal(t, 10.0, specs[0].DiskGB) + assert.Equal(t, "n1-standard-1", specs[1].Type) + assert.Equal(t, 10.0, specs[1].DiskGB) + assert.Equal(t, "m8i-flex.8xlarge", specs[2].Type) + assert.Equal(t, 500.0, specs[2].DiskGB) +} + func TestParseJSONInput(t *testing.T) { // Simulated JSON output from gpu-search --json jsonInput := `[ diff --git a/pkg/cmd/gpusearch/gpusearch.go b/pkg/cmd/gpusearch/gpusearch.go index be9ac71f..e9f45cd0 100644 --- a/pkg/cmd/gpusearch/gpusearch.go +++ b/pkg/cmd/gpusearch/gpusearch.go @@ -57,21 +57,23 @@ type WorkspaceGroup struct { // InstanceType represents an instance type from the API type InstanceType struct { - Type string `json:"type"` - SupportedGPUs []GPU `json:"supported_gpus"` - SupportedStorage []Storage `json:"supported_storage"` - Memory string `json:"memory"` - VCPU int `json:"vcpu"` - BasePrice BasePrice `json:"base_price"` - Location string `json:"location"` - SubLocation string `json:"sub_location"` - AvailableLocations []string `json:"available_locations"` - Provider string `json:"provider"` - WorkspaceGroups []WorkspaceGroup `json:"workspace_groups"` - EstimatedDeployTime string `json:"estimated_deploy_time"` - Stoppable bool `json:"stoppable"` - Rebootable bool `json:"rebootable"` - CanModifyFirewallRules bool `json:"can_modify_firewall_rules"` + Type string `json:"type"` + SupportedGPUs []GPU `json:"supported_gpus"` + SupportedStorage []Storage `json:"supported_storage"` + SupportedArchitectures []string `json:"supported_architectures"` + Memory string `json:"memory"` + InstanceMemoryBytes MemoryBytes `json:"memory_bytes"` + VCPU int `json:"vcpu"` + BasePrice BasePrice `json:"base_price"` + Location string `json:"location"` + SubLocation string `json:"sub_location"` + AvailableLocations []string `json:"available_locations"` + Provider string `json:"provider"` + WorkspaceGroups []WorkspaceGroup `json:"workspace_groups"` + EstimatedDeployTime string `json:"estimated_deploy_time"` + Stoppable bool `json:"stoppable"` + Rebootable bool `json:"rebootable"` + CanModifyFirewallRules bool `json:"can_modify_firewall_rules"` } // InstanceTypesResponse represents the API response @@ -98,110 +100,172 @@ func (r *AllInstanceTypesResponse) GetWorkspaceGroupID(instanceType string) stri // GPUSearchStore defines the interface for fetching instance types type GPUSearchStore interface { - GetInstanceTypes() (*InstanceTypesResponse, error) + GetInstanceTypes(includeCPU bool) (*InstanceTypesResponse, error) } var ( - long = `Search and filter GPU instance types available on Brev. + searchLong = `Search instance types available on Brev. -Filter instances by GPU name, provider, VRAM, total VRAM, GPU compute capability, disk size, and boot time. -Sort results by various columns to find the best instance for your needs. +Use 'brev search gpu' (default) to find GPU instances. +Use 'brev search cpu' to find CPU-only instances. Features column shows instance capabilities: S = Stoppable (can stop and restart without losing data) R = Rebootable (can reboot the instance) P = Flex Ports (can modify firewall/port rules)` - example = ` - # List all GPU instances + gpuExample = ` + # List all GPU instances (default) brev search + brev search gpu # Filter by GPU name (case-insensitive, partial match) - brev search --gpu-name A100 - brev search --gpu-name "L40S" - - # Filter by provider/cloud (case-insensitive, partial match) - brev search --provider aws - brev search --provider gcp + brev search gpu --gpu-name A100 # Filter by minimum VRAM per GPU (in GB) - brev search --min-vram 24 + brev search gpu --min-vram 24 - # Filter by minimum total VRAM (in GB) - brev search --min-total-vram 80 + # Wide output (includes RAM, ARCH columns) + brev search gpu --wide - # Filter by minimum GPU compute capability - brev search --min-capability 8.0 + # Sort and combine filters + brev search gpu --gpu-name H100 --sort price + brev search gpu --stoppable --min-total-vram 40 --sort price +` - # Filter by minimum disk size (in GB) - brev search --min-disk 500 + cpuExample = ` + # List all CPU instances + brev search cpu - # Filter by maximum boot time (in minutes) - brev search --max-boot-time 5 + # Filter by provider + brev search cpu --provider aws - # Filter by instance features - brev search --stoppable # Only show instances that can be stopped/restarted - brev search --rebootable # Only show instances that can be rebooted - brev search --flex-ports # Only show instances with configurable firewall rules + # Filter by minimum RAM + brev search cpu --min-ram 64 - # Sort by different columns (price, gpu-count, vram, total-vram, vcpu, provider, disk, boot-time) - brev search --sort price - brev search --sort boot-time - brev search --sort disk --desc + # Filter by architecture + brev search cpu --arch arm64 - # Combine filters - brev search --gpu-name A100 --min-vram 40 --sort price - brev search --gpu-name H100 --max-boot-time 3 --sort price - brev search --stoppable --min-total-vram 40 --sort price + # Sort by price + brev search cpu --sort price ` ) -// NewCmdGPUSearch creates the search command +// sharedFlags holds flags shared between gpu and cpu subcommands +type sharedFlags struct { + provider string + minVCPU int + minDisk float64 + maxBootTime int + stoppable bool + rebootable bool + flexPorts bool + sortBy string + descending bool + jsonOutput bool +} + +// addSharedFlags adds common flags to a command +func addSharedFlags(cmd *cobra.Command, f *sharedFlags) { + cmd.Flags().StringVarP(&f.provider, "provider", "p", "", "Filter by provider/cloud (case-insensitive, partial match)") + cmd.Flags().IntVar(&f.minVCPU, "min-vcpu", 0, "Minimum number of vCPUs") + cmd.Flags().Float64Var(&f.minDisk, "min-disk", 0, "Minimum disk size in GB") + cmd.Flags().IntVar(&f.maxBootTime, "max-boot-time", 0, "Maximum boot time in minutes") + cmd.Flags().BoolVar(&f.stoppable, "stoppable", false, "Only show instances that can be stopped and restarted") + cmd.Flags().BoolVar(&f.rebootable, "rebootable", false, "Only show instances that can be rebooted") + cmd.Flags().BoolVar(&f.flexPorts, "flex-ports", false, "Only show instances with configurable firewall/port rules") + cmd.Flags().StringVarP(&f.sortBy, "sort", "s", "price", "Sort by column (see --help for options)") + cmd.Flags().BoolVarP(&f.descending, "desc", "d", false, "Sort in descending order") + cmd.Flags().BoolVar(&f.jsonOutput, "json", false, "Output results as JSON") +} + +// NewCmdGPUSearch creates the search command with gpu and cpu subcommands func NewCmdGPUSearch(t *terminal.Terminal, store GPUSearchStore) *cobra.Command { + // GPU-specific flags (also used by parent default) var gpuName string - var provider string var minVRAM float64 var minTotalVRAM float64 var minCapability float64 - var minDisk float64 - var maxBootTime int - var stoppable bool - var rebootable bool - var flexPorts bool - var sortBy string - var descending bool - var jsonOutput bool + var wide bool + var shared sharedFlags cmd := &cobra.Command{ Annotations: map[string]string{"workspace": ""}, Use: "search", Aliases: []string{"gpu-search", "gpu", "gpus", "gpu-list"}, DisableFlagsInUseLine: true, - Short: "Search and filter GPU instance types", - Long: long, - Example: example, + Short: "Search and filter instance types", + Long: searchLong, + Example: gpuExample, RunE: func(cmd *cobra.Command, args []string) error { - err := RunGPUSearch(t, store, gpuName, provider, minVRAM, minTotalVRAM, minCapability, minDisk, maxBootTime, stoppable, rebootable, flexPorts, sortBy, descending, jsonOutput) - if err != nil { - return breverrors.WrapAndTrace(err) - } - return nil + // Default behavior: GPU search + return RunGPUSearch(t, store, gpuName, shared.provider, minVRAM, minTotalVRAM, minCapability, shared.minDisk, shared.minVCPU, shared.maxBootTime, shared.stoppable, shared.rebootable, shared.flexPorts, shared.sortBy, shared.descending, shared.jsonOutput, wide) + }, + } + + // GPU-specific flags on parent (for default gpu behavior) + cmd.Flags().StringVarP(&gpuName, "gpu-name", "g", "", "Filter by GPU name (case-insensitive, partial match)") + cmd.Flags().Float64VarP(&minVRAM, "min-vram", "v", 0, "Minimum VRAM per GPU in GB") + cmd.Flags().Float64VarP(&minTotalVRAM, "min-total-vram", "t", 0, "Minimum total VRAM (GPU count * VRAM) in GB") + cmd.Flags().Float64VarP(&minCapability, "min-capability", "c", 0, "Minimum GPU compute capability (e.g., 8.0 for Ampere)") + cmd.Flags().BoolVarP(&wide, "wide", "w", false, "Show additional columns (RAM, ARCH)") + addSharedFlags(cmd, &shared) + + // Add subcommands + cmd.AddCommand(newCmdGPUSubcommand(t, store)) + cmd.AddCommand(newCmdCPUSubcommand(t, store)) + + return cmd +} + +// newCmdGPUSubcommand creates the explicit 'gpu' subcommand +func newCmdGPUSubcommand(t *terminal.Terminal, store GPUSearchStore) *cobra.Command { + var gpuName string + var minVRAM float64 + var minTotalVRAM float64 + var minCapability float64 + var wide bool + var shared sharedFlags + + cmd := &cobra.Command{ + Use: "gpu", + DisableFlagsInUseLine: true, + Short: "Search GPU instance types", + Example: gpuExample, + RunE: func(cmd *cobra.Command, args []string) error { + return RunGPUSearch(t, store, gpuName, shared.provider, minVRAM, minTotalVRAM, minCapability, shared.minDisk, shared.minVCPU, shared.maxBootTime, shared.stoppable, shared.rebootable, shared.flexPorts, shared.sortBy, shared.descending, shared.jsonOutput, wide) }, } cmd.Flags().StringVarP(&gpuName, "gpu-name", "g", "", "Filter by GPU name (case-insensitive, partial match)") - cmd.Flags().StringVarP(&provider, "provider", "p", "", "Filter by provider/cloud (case-insensitive, partial match)") cmd.Flags().Float64VarP(&minVRAM, "min-vram", "v", 0, "Minimum VRAM per GPU in GB") cmd.Flags().Float64VarP(&minTotalVRAM, "min-total-vram", "t", 0, "Minimum total VRAM (GPU count * VRAM) in GB") cmd.Flags().Float64VarP(&minCapability, "min-capability", "c", 0, "Minimum GPU compute capability (e.g., 8.0 for Ampere)") - cmd.Flags().Float64Var(&minDisk, "min-disk", 0, "Minimum disk size in GB") - cmd.Flags().IntVar(&maxBootTime, "max-boot-time", 0, "Maximum boot time in minutes") - cmd.Flags().BoolVar(&stoppable, "stoppable", false, "Only show instances that can be stopped and restarted") - cmd.Flags().BoolVar(&rebootable, "rebootable", false, "Only show instances that can be rebooted") - cmd.Flags().BoolVar(&flexPorts, "flex-ports", false, "Only show instances with configurable firewall/port rules") - cmd.Flags().StringVarP(&sortBy, "sort", "s", "price", "Sort by: price, gpu-count, vram, total-vram, vcpu, type, provider, disk, boot-time") - cmd.Flags().BoolVarP(&descending, "desc", "d", false, "Sort in descending order") - cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output results as JSON") + cmd.Flags().BoolVarP(&wide, "wide", "w", false, "Show additional columns (RAM, ARCH)") + addSharedFlags(cmd, &shared) + + return cmd +} + +// newCmdCPUSubcommand creates the 'cpu' subcommand +func newCmdCPUSubcommand(t *terminal.Terminal, store GPUSearchStore) *cobra.Command { + var minRAM float64 + var arch string + var shared sharedFlags + + cmd := &cobra.Command{ + Use: "cpu", + DisableFlagsInUseLine: true, + Short: "Search CPU-only instance types", + Example: cpuExample, + RunE: func(cmd *cobra.Command, args []string) error { + return RunCPUSearch(t, store, shared.provider, arch, minRAM, shared.minDisk, shared.minVCPU, shared.maxBootTime, shared.stoppable, shared.rebootable, shared.flexPorts, shared.sortBy, shared.descending, shared.jsonOutput) + }, + } + + cmd.Flags().Float64Var(&minRAM, "min-ram", 0, "Minimum RAM in GB") + cmd.Flags().StringVar(&arch, "arch", "", "Filter by architecture (e.g., x86_64, arm64)") + addSharedFlags(cmd, &shared) return cmd } @@ -218,6 +282,8 @@ type GPUInstanceInfo struct { Capability float64 `json:"capability"` VCPUs int `json:"vcpus"` Memory string `json:"memory"` + RAMInGB float64 `json:"ram_gb"` + Arch string `json:"arch"` DiskMin float64 `json:"disk_min_gb"` DiskMax float64 `json:"disk_max_gb"` DiskPricePerMo float64 `json:"disk_price_per_gb_mo,omitempty"` // $/GB/month for flexible storage @@ -237,15 +303,14 @@ func IsStdoutPiped() bool { } // RunGPUSearch executes the GPU search with filters and sorting -func RunGPUSearch(t *terminal.Terminal, store GPUSearchStore, gpuName, provider string, minVRAM, minTotalVRAM, minCapability, minDisk float64, maxBootTime int, stoppable, rebootable, flexPorts bool, sortBy string, descending, jsonOutput bool) error { +func RunGPUSearch(t *terminal.Terminal, store GPUSearchStore, gpuName, provider string, minVRAM, minTotalVRAM, minCapability, minDisk float64, minVCPU, maxBootTime int, stoppable, rebootable, flexPorts bool, sortBy string, descending, jsonOutput, wide bool) error { if err := validateSortOption(sortBy); err != nil { return err } - // Detect if stdout is piped (for plain table output) piped := IsStdoutPiped() - response, err := store.GetInstanceTypes() + response, err := store.GetInstanceTypes(false) if err != nil { return breverrors.WrapAndTrace(err) } @@ -254,24 +319,49 @@ func RunGPUSearch(t *terminal.Terminal, store GPUSearchStore, gpuName, provider return displayEmptyResults(t, "No instance types found", jsonOutput, piped) } - // Process and filter instances instances := ProcessInstances(response.Items) - // Apply filters - filtered := FilterInstances(instances, gpuName, provider, minVRAM, minTotalVRAM, minCapability, minDisk, maxBootTime, stoppable, rebootable, flexPorts) + // Filter to GPU-only instances + filtered := FilterInstances(instances, gpuName, provider, minVRAM, minTotalVRAM, minCapability, minDisk, minVCPU, maxBootTime, stoppable, rebootable, flexPorts, false) if len(filtered) == 0 { return displayEmptyResults(t, "No GPU instances match the specified filters", jsonOutput, piped) } - // Set target disk for each instance setTargetDisks(filtered, minDisk) - - // Sort instances SortInstances(filtered, sortBy, descending) + return DisplayGPUResults(t, filtered, jsonOutput, piped, wide) +} + +// RunCPUSearch executes the CPU search with filters and sorting +func RunCPUSearch(t *terminal.Terminal, store GPUSearchStore, provider, arch string, minRAM, minDisk float64, minVCPU, maxBootTime int, stoppable, rebootable, flexPorts bool, sortBy string, descending, jsonOutput bool) error { + if err := validateSortOption(sortBy); err != nil { + return err + } + + piped := IsStdoutPiped() + + response, err := store.GetInstanceTypes(true) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + if response == nil || len(response.Items) == 0 { + return displayEmptyResults(t, "No instance types found", jsonOutput, piped) + } + + instances := ProcessInstances(response.Items) + + // Filter to CPU-only instances + filtered := FilterCPUInstances(instances, provider, arch, minRAM, minDisk, minVCPU, maxBootTime, stoppable, rebootable, flexPorts) + + if len(filtered) == 0 { + return displayEmptyResults(t, "No CPU instances match the specified filters", jsonOutput, piped) + } - // Display results - return DisplayResults(t, filtered, jsonOutput, piped) + setTargetDisks(filtered, minDisk) + SortInstances(filtered, sortBy, descending) + return DisplayCPUResults(t, filtered, jsonOutput, piped) } // validateSortOption returns an error if sortBy is not a valid option @@ -312,22 +402,44 @@ func setTargetDisks(instances []GPUInstanceInfo, minDisk float64) { } } -// displayResults renders the GPU instances in the appropriate format -func DisplayResults(t *terminal.Terminal, instances []GPUInstanceInfo, jsonOutput, piped bool) error { +// DisplayGPUResults renders GPU instances in the appropriate format +func DisplayGPUResults(t *terminal.Terminal, instances []GPUInstanceInfo, jsonOutput, piped, wide bool) error { if jsonOutput { - return displayGPUJSON(instances) + return displayJSON(instances) } if piped { - displayGPUTablePlain(instances) + if wide { + displayGPUTablePlainWide(instances) + } else { + displayGPUTablePlain(instances) + } return nil } - displayGPUTable(t, instances) + if wide { + displayGPUTableWide(t, instances) + } else { + displayGPUTable(t, instances) + } t.Vprintf("\n%s\n", t.Green(fmt.Sprintf("Found %d GPU instance types", len(instances)))) return nil } -// displayGPUJSON outputs the GPU instances as JSON -func displayGPUJSON(instances []GPUInstanceInfo) error { +// DisplayCPUResults renders CPU instances in the appropriate format +func DisplayCPUResults(t *terminal.Terminal, instances []GPUInstanceInfo, jsonOutput, piped bool) error { + if jsonOutput { + return displayJSON(instances) + } + if piped { + displayCPUTablePlain(instances) + return nil + } + displayCPUTable(t, instances) + t.Vprintf("\n%s\n", t.Green(fmt.Sprintf("Found %d CPU instance types", len(instances)))) + return nil +} + +// displayJSON outputs instances as JSON +func displayJSON(instances []GPUInstanceInfo) error { output, err := json.MarshalIndent(instances, "", " ") if err != nil { return breverrors.WrapAndTrace(err) @@ -369,6 +481,8 @@ var validSortOptions = map[string]bool{ "provider": true, "disk": true, "boot-time": true, + "ram": true, + "arch": true, } // parseToGB converts size/memory strings like "22GiB360MiB", "16TiB", "2TiB768GiB" to GB @@ -392,6 +506,19 @@ func parseSizeToGB(size string) float64 { return parseToGB(size) } +// memoryBytesToGB converts a MemoryBytes struct to GB +func memoryBytesToGB(mb MemoryBytes) float64 { + switch mb.Unit { + case "MiB", "MB": + return float64(mb.Value) / 1024 + case "GiB", "GB": + return float64(mb.Value) + case "TiB", "TB": + return float64(mb.Value) * 1024 + } + return 0 +} + // parseDurationToSeconds parses Go duration strings like "7m0s", "1m30s" to seconds func parseDurationToSeconds(duration string) int { var totalSeconds int @@ -578,36 +705,64 @@ func ProcessInstances(items []InstanceType) []GPUInstanceInfo { var instances []GPUInstanceInfo for _, item := range items { - if len(item.SupportedGPUs) == 0 { - continue // Skip non-GPU instances - } - // Extract disk size and price info from first storage entry diskMin, diskMax, diskPricePerMo := extractDiskInfo(item.SupportedStorage) // Extract boot time bootTime := parseDurationToSeconds(item.EstimatedDeployTime) + price := 0.0 + if item.BasePrice.Amount != "" { + price, _ = strconv.ParseFloat(item.BasePrice.Amount, 64) + } + + // Extract architecture + arch := "-" + if len(item.SupportedArchitectures) > 0 { + arch = item.SupportedArchitectures[0] + } + + // Parse instance RAM + ramInGB := parseMemoryToGB(item.Memory) + if ramInGB == 0 && item.InstanceMemoryBytes.Value > 0 { + ramInGB = memoryBytesToGB(item.InstanceMemoryBytes) + } + + if len(item.SupportedGPUs) == 0 { + // CPU-only instance + instances = append(instances, GPUInstanceInfo{ + Type: item.Type, + Cloud: extractCloud(item.Type, item.Provider), + Provider: item.Provider, + GPUName: "-", + GPUCount: 0, + VCPUs: item.VCPU, + Memory: item.Memory, + RAMInGB: ramInGB, + Arch: arch, + DiskMin: diskMin, + DiskMax: diskMax, + DiskPricePerMo: diskPricePerMo, + BootTime: bootTime, + Stoppable: item.Stoppable, + Rebootable: item.Rebootable, + FlexPorts: item.CanModifyFirewallRules, + PricePerHour: price, + Manufacturer: "cpu", + }) + continue + } + for _, gpu := range item.SupportedGPUs { vramPerGPU := parseMemoryToGB(gpu.Memory) // Also check memory_bytes as fallback if vramPerGPU == 0 && gpu.MemoryBytes.Value > 0 { - // Convert based on unit - if gpu.MemoryBytes.Unit == "MiB" { - vramPerGPU = float64(gpu.MemoryBytes.Value) / 1024 // MiB to GiB - } else if gpu.MemoryBytes.Unit == "GiB" { - vramPerGPU = float64(gpu.MemoryBytes.Value) - } + vramPerGPU = memoryBytesToGB(gpu.MemoryBytes) } totalVRAM := vramPerGPU * float64(gpu.Count) capability := getGPUCapability(gpu.Name) - price := 0.0 - if item.BasePrice.Amount != "" { - price, _ = strconv.ParseFloat(item.BasePrice.Amount, 64) - } - instances = append(instances, GPUInstanceInfo{ Type: item.Type, Cloud: extractCloud(item.Type, item.Provider), @@ -619,6 +774,8 @@ func ProcessInstances(items []InstanceType) []GPUInstanceInfo { Capability: capability, VCPUs: item.VCPU, Memory: item.Memory, + RAMInGB: ramInGB, + Arch: arch, DiskMin: diskMin, DiskMax: diskMax, DiskPricePerMo: diskPricePerMo, @@ -643,6 +800,7 @@ type FilterOptions struct { MinTotalVRAM float64 MinCapability float64 MinDisk float64 + MinVCPU int MaxBootTime int // in minutes Stoppable bool Rebootable bool @@ -651,8 +809,8 @@ type FilterOptions struct { // matchesStringFilters checks GPU name and provider filters func (f *FilterOptions) matchesStringFilters(inst GPUInstanceInfo) bool { - // Filter out non-NVIDIA GPUs (AMD, Intel/Habana, etc.) - if !strings.Contains(strings.ToUpper(inst.Manufacturer), "NVIDIA") { + // Allow CPU-only instances through; filter out non-NVIDIA GPUs (AMD, Intel/Habana, etc.) + if inst.Manufacturer != "cpu" && !strings.Contains(strings.ToUpper(inst.Manufacturer), "NVIDIA") { return false } // Filter by GPU name (case-insensitive partial match) @@ -666,8 +824,11 @@ func (f *FilterOptions) matchesStringFilters(inst GPUInstanceInfo) bool { return true } -// matchesNumericFilters checks VRAM, capability, disk, and boot time filters +// matchesNumericFilters checks VRAM, capability, disk, vCPU, and boot time filters func (f *FilterOptions) matchesNumericFilters(inst GPUInstanceInfo) bool { + if f.MinVCPU > 0 && inst.VCPUs < f.MinVCPU { + return false + } if f.MinVRAM > 0 && inst.VRAMPerGPU < f.MinVRAM { return false } @@ -708,8 +869,8 @@ func (f *FilterOptions) matchesFilter(inst GPUInstanceInfo) bool { f.matchesFeatureFilters(inst) } -// FilterInstances applies all filters to the instance list -func FilterInstances(instances []GPUInstanceInfo, gpuName, provider string, minVRAM, minTotalVRAM, minCapability, minDisk float64, maxBootTime int, stoppable, rebootable, flexPorts bool) []GPUInstanceInfo { +// FilterInstances applies all filters to the instance list. When gpuOnly is true, CPU-only instances are excluded. +func FilterInstances(instances []GPUInstanceInfo, gpuName, provider string, minVRAM, minTotalVRAM, minCapability, minDisk float64, minVCPU, maxBootTime int, stoppable, rebootable, flexPorts, gpuOnly bool) []GPUInstanceInfo { opts := &FilterOptions{ GPUName: gpuName, Provider: provider, @@ -717,6 +878,7 @@ func FilterInstances(instances []GPUInstanceInfo, gpuName, provider string, minV MinTotalVRAM: minTotalVRAM, MinCapability: minCapability, MinDisk: minDisk, + MinVCPU: minVCPU, MaxBootTime: maxBootTime, Stoppable: stoppable, Rebootable: rebootable, @@ -725,6 +887,9 @@ func FilterInstances(instances []GPUInstanceInfo, gpuName, provider string, minV var filtered []GPUInstanceInfo for _, inst := range instances { + if gpuOnly && inst.Manufacturer == "cpu" { + continue + } if opts.matchesFilter(inst) { filtered = append(filtered, inst) } @@ -732,6 +897,53 @@ func FilterInstances(instances []GPUInstanceInfo, gpuName, provider string, minV return filtered } +// FilterCPUInstances filters to CPU-only instances and applies CPU-specific filters +func FilterCPUInstances(instances []GPUInstanceInfo, provider, arch string, minRAM, minDisk float64, minVCPU, maxBootTime int, stoppable, rebootable, flexPorts bool) []GPUInstanceInfo { + var filtered []GPUInstanceInfo + for _, inst := range instances { + // CPU-only: skip GPU instances + if inst.Manufacturer != "cpu" { + continue + } + // Provider filter + if provider != "" && !strings.Contains(strings.ToLower(inst.Provider), strings.ToLower(provider)) { + continue + } + // Arch filter + if arch != "" && !strings.Contains(strings.ToLower(inst.Arch), strings.ToLower(arch)) { + continue + } + // Min vCPU filter + if minVCPU > 0 && inst.VCPUs < minVCPU { + continue + } + // Min RAM filter + if minRAM > 0 && inst.RAMInGB < minRAM { + continue + } + // Min disk filter + if minDisk > 0 && inst.DiskMax < minDisk { + continue + } + // Max boot time filter + if maxBootTime > 0 && (inst.BootTime == 0 || inst.BootTime > maxBootTime*60) { + continue + } + // Feature filters + if stoppable && !inst.Stoppable { + continue + } + if rebootable && !inst.Rebootable { + continue + } + if flexPorts && !inst.FlexPorts { + continue + } + filtered = append(filtered, inst) + } + return filtered +} + // SortInstances sorts the instance list by the specified column func SortInstances(instances []GPUInstanceInfo, sortBy string, descending bool) { sort.Slice(instances, func(i, j int) bool { @@ -755,6 +967,10 @@ func SortInstances(instances []GPUInstanceInfo, sortBy string, descending bool) less = instances[i].Provider < instances[j].Provider case "disk": less = instances[i].DiskMax < instances[j].DiskMax + case "ram": + less = instances[i].RAMInGB < instances[j].RAMInGB + case "arch": + less = instances[i].Arch < instances[j].Arch case "boot-time": // Instances with no boot time (0) should always appear last switch { @@ -845,6 +1061,7 @@ type formattedInstanceFields struct { VRAM string TotalVRAM string Capability string + RAM string Disk string DiskPrice string Boot string @@ -881,10 +1098,18 @@ func formatInstanceFields(inst GPUInstanceInfo, includeUnits bool) formattedInst providerStr = fmt.Sprintf("%s:%s", inst.Cloud, inst.Provider) } + var ramStr string + if includeUnits { + ramStr = fmt.Sprintf("%.0f GB", inst.RAMInGB) + } else { + ramStr = fmt.Sprintf("%.0f", inst.RAMInGB) + } + return formattedInstanceFields{ VRAM: vramStr, TotalVRAM: totalVramStr, Capability: capStr, + RAM: ramStr, Disk: formatDiskSize(inst.DiskMin, inst.DiskMax), DiskPrice: diskPriceStr, Boot: formatBootTime(inst.BootTime), @@ -961,3 +1186,131 @@ func displayGPUTablePlain(instances []GPUInstanceInfo) { ta.Render() } + +// displayGPUTableWide renders the GPU instances with additional RAM and ARCH columns +func displayGPUTableWide(t *terminal.Terminal, instances []GPUInstanceInfo) { + ta := table.NewWriter() + ta.SetOutputMirror(os.Stdout) + ta.Style().Options = getBrevTableOptions() + + header := table.Row{"TYPE", "PROVIDER", "GPU", "COUNT", "VRAM/GPU", "TOTAL VRAM", "CAPABILITY", "RAM", "ARCH", "DISK", "$/GB/MO", "BOOT", "FEATURES", "VCPUs", "$/HR"} + ta.AppendHeader(header) + + for _, inst := range instances { + f := formatInstanceFields(inst, true) + row := table.Row{ + inst.Type, + f.Provider, + t.Green(inst.GPUName), + inst.GPUCount, + f.VRAM, + f.TotalVRAM, + f.Capability, + f.RAM, + inst.Arch, + f.Disk, + f.DiskPrice, + f.Boot, + f.Features, + inst.VCPUs, + f.Price, + } + ta.AppendRow(row) + } + + ta.Render() +} + +// displayGPUTablePlainWide renders the wide GPU table for piping +func displayGPUTablePlainWide(instances []GPUInstanceInfo) { + ta := table.NewWriter() + ta.SetOutputMirror(os.Stdout) + ta.Style().Options = getBrevTableOptions() + + header := table.Row{"TYPE", "TARGET_DISK", "PROVIDER", "GPU", "COUNT", "VRAM/GPU", "TOTAL_VRAM", "CAPABILITY", "RAM", "ARCH", "DISK", "$/GB/MO", "BOOT", "FEATURES", "VCPUs", "$/HR"} + ta.AppendHeader(header) + + for _, inst := range instances { + f := formatInstanceFields(inst, false) + row := table.Row{ + inst.Type, + f.TargetDisk, + f.Provider, + inst.GPUName, + inst.GPUCount, + f.VRAM, + f.TotalVRAM, + f.Capability, + f.RAM, + inst.Arch, + f.Disk, + f.DiskPrice, + f.Boot, + f.Features, + inst.VCPUs, + f.Price, + } + ta.AppendRow(row) + } + + ta.Render() +} + +// displayCPUTable renders CPU instances as a colored table +func displayCPUTable(t *terminal.Terminal, instances []GPUInstanceInfo) { + ta := table.NewWriter() + ta.SetOutputMirror(os.Stdout) + ta.Style().Options = getBrevTableOptions() + + header := table.Row{"TYPE", "PROVIDER", "VCPUs", "RAM", "ARCH", "DISK", "$/GB/MO", "BOOT", "FEATURES", "$/HR"} + ta.AppendHeader(header) + + for _, inst := range instances { + f := formatInstanceFields(inst, true) + row := table.Row{ + inst.Type, + f.Provider, + inst.VCPUs, + f.RAM, + inst.Arch, + f.Disk, + f.DiskPrice, + f.Boot, + f.Features, + f.Price, + } + ta.AppendRow(row) + } + + ta.Render() +} + +// displayCPUTablePlain renders CPU instances as a plain table for piping +func displayCPUTablePlain(instances []GPUInstanceInfo) { + ta := table.NewWriter() + ta.SetOutputMirror(os.Stdout) + ta.Style().Options = getBrevTableOptions() + + header := table.Row{"TYPE", "TARGET_DISK", "PROVIDER", "VCPUs", "RAM", "ARCH", "DISK", "$/GB/MO", "BOOT", "FEATURES", "$/HR"} + ta.AppendHeader(header) + + for _, inst := range instances { + f := formatInstanceFields(inst, false) + row := table.Row{ + inst.Type, + f.TargetDisk, + f.Provider, + inst.VCPUs, + f.RAM, + inst.Arch, + f.Disk, + f.DiskPrice, + f.Boot, + f.Features, + f.Price, + } + ta.AppendRow(row) + } + + ta.Render() +} diff --git a/pkg/cmd/gpusearch/gpusearch_test.go b/pkg/cmd/gpusearch/gpusearch_test.go index 1959b073..01466a7b 100644 --- a/pkg/cmd/gpusearch/gpusearch_test.go +++ b/pkg/cmd/gpusearch/gpusearch_test.go @@ -12,7 +12,7 @@ type MockGPUSearchStore struct { Err error } -func (m *MockGPUSearchStore) GetInstanceTypes() (*InstanceTypesResponse, error) { +func (m *MockGPUSearchStore) GetInstanceTypes(_ bool) (*InstanceTypesResponse, error) { if m.Err != nil { return nil, m.Err } @@ -168,19 +168,19 @@ func TestFilterInstancesByGPUName(t *testing.T) { instances := ProcessInstances(response.Items) // Filter by A10G - filtered := FilterInstances(instances, "A10G", "", 0, 0, 0, 0, 0, false, false, false) + filtered := FilterInstances(instances, "A10G", "", 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 2, "Should have 2 A10G instances") // Filter by V100 - filtered = FilterInstances(instances, "V100", "", 0, 0, 0, 0, 0, false, false, false) + filtered = FilterInstances(instances, "V100", "", 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 2, "Should have 2 V100 instances") // Filter by lowercase (case-insensitive) - filtered = FilterInstances(instances, "v100", "", 0, 0, 0, 0, 0, false, false, false) + filtered = FilterInstances(instances, "v100", "", 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 2, "Should have 2 V100 instances (case-insensitive)") // Filter by partial match - filtered = FilterInstances(instances, "A1", "", 0, 0, 0, 0, 0, false, false, false) + filtered = FilterInstances(instances, "A1", "", 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 3, "Should have 3 instances matching 'A1' (A10G and A100)") } @@ -189,11 +189,11 @@ func TestFilterInstancesByMinVRAM(t *testing.T) { instances := ProcessInstances(response.Items) // Filter by min VRAM 24GB - filtered := FilterInstances(instances, "", "", 24, 0, 0, 0, 0, false, false, false) + filtered := FilterInstances(instances, "", "", 24, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 4, "Should have 4 instances with >= 24GB VRAM") // Filter by min VRAM 40GB - filtered = FilterInstances(instances, "", "", 40, 0, 0, 0, 0, false, false, false) + filtered = FilterInstances(instances, "", "", 40, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 1, "Should have 1 instance with >= 40GB VRAM") assert.Equal(t, "A100", filtered[0].GPUName) } @@ -203,11 +203,11 @@ func TestFilterInstancesByMinTotalVRAM(t *testing.T) { instances := ProcessInstances(response.Items) // Filter by min total VRAM 60GB - filtered := FilterInstances(instances, "", "", 0, 60, 0, 0, 0, false, false, false) + filtered := FilterInstances(instances, "", "", 0, 60, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 2, "Should have 2 instances with >= 60GB total VRAM") // Filter by min total VRAM 300GB - filtered = FilterInstances(instances, "", "", 0, 300, 0, 0, 0, false, false, false) + filtered = FilterInstances(instances, "", "", 0, 300, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 1, "Should have 1 instance with >= 300GB total VRAM") assert.Equal(t, "p4d.24xlarge", filtered[0].Type) } @@ -217,11 +217,11 @@ func TestFilterInstancesByMinCapability(t *testing.T) { instances := ProcessInstances(response.Items) // Filter by capability >= 8.0 - filtered := FilterInstances(instances, "", "", 0, 0, 8.0, 0, 0, false, false, false) + filtered := FilterInstances(instances, "", "", 0, 0, 8.0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 4, "Should have 4 instances with capability >= 8.0") // Filter by capability >= 8.5 - filtered = FilterInstances(instances, "", "", 0, 0, 8.5, 0, 0, false, false, false) + filtered = FilterInstances(instances, "", "", 0, 0, 8.5, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 3, "Should have 3 instances with capability >= 8.5") } @@ -230,11 +230,11 @@ func TestFilterInstancesCombined(t *testing.T) { instances := ProcessInstances(response.Items) // Filter by GPU name and min VRAM - filtered := FilterInstances(instances, "A10G", "", 24, 0, 0, 0, 0, false, false, false) + filtered := FilterInstances(instances, "A10G", "", 24, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 2, "Should have 2 A10G instances with >= 24GB VRAM") // Filter by GPU name, min VRAM, and capability - filtered = FilterInstances(instances, "", "", 24, 0, 8.5, 0, 0, false, false, false) + filtered = FilterInstances(instances, "", "", 24, 0, 8.5, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 3, "Should have 3 instances with >= 24GB VRAM and capability >= 8.5") } @@ -336,11 +336,11 @@ func TestEmptyInstanceTypes(t *testing.T) { assert.Len(t, instances, 0, "Should have 0 instances") - filtered := FilterInstances(instances, "A100", "", 0, 0, 0, 0, 0, false, false, false) + filtered := FilterInstances(instances, "A100", "", 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 0, "Filtered should also be empty") } -func TestNonGPUInstancesAreFiltered(t *testing.T) { +func TestNonGPUInstancesAreIncludedInProcessing(t *testing.T) { response := &InstanceTypesResponse{ Items: []InstanceType{ { @@ -363,8 +363,45 @@ func TestNonGPUInstancesAreFiltered(t *testing.T) { } instances := ProcessInstances(response.Items) - assert.Len(t, instances, 1, "Should only have 1 GPU instance, non-GPU instances should be filtered") - assert.Equal(t, "g5.xlarge", instances[0].Type) + assert.Len(t, instances, 2, "Should include both CPU and GPU instances") + assert.Equal(t, "m5.xlarge", instances[0].Type) + assert.Equal(t, "cpu", instances[0].Manufacturer) + assert.Equal(t, "-", instances[0].GPUName) + assert.Equal(t, "g5.xlarge", instances[1].Type) +} + +func TestNonGPUInstancesFilteredByDefault(t *testing.T) { + response := &InstanceTypesResponse{ + Items: []InstanceType{ + { + Type: "m5.xlarge", + SupportedGPUs: []GPU{}, // No GPUs + Memory: "16GiB", + VCPU: 4, + BasePrice: BasePrice{Currency: "USD", Amount: "0.192"}, + }, + { + Type: "g5.xlarge", + SupportedGPUs: []GPU{ + {Count: 1, Name: "A10G", Manufacturer: "NVIDIA", Memory: "24GiB"}, + }, + Memory: "16GiB", + VCPU: 4, + BasePrice: BasePrice{Currency: "USD", Amount: "1.006"}, + }, + }, + } + + instances := ProcessInstances(response.Items) + + // gpuOnly=true should filter out CPU instances + filtered := FilterInstances(instances, "", "", 0, 0, 0, 0, 0, 0, false, false, false, true) + assert.Len(t, filtered, 1, "gpuOnly should exclude CPU instances") + assert.Equal(t, "g5.xlarge", filtered[0].Type) + + // gpuOnly=false should keep CPU instances + filtered = FilterInstances(instances, "", "", 0, 0, 0, 0, 0, 0, false, false, false, false) + assert.Len(t, filtered, 2, "Without gpuOnly, both CPU and GPU instances pass") } func TestMemoryBytesAsFallback(t *testing.T) { @@ -427,7 +464,7 @@ func TestFilterByMaxBootTimeExcludesUnknown(t *testing.T) { assert.Len(t, instances, 3, "Should have 3 instances before filtering") // Filter by max boot time of 10 minutes - should exclude unknown and slow-boot - filtered := FilterInstances(instances, "", "", 0, 0, 0, 0, 10, false, false, false) + filtered := FilterInstances(instances, "", "", 0, 0, 0, 0, 0, 10, false, false, false, true) assert.Len(t, filtered, 1, "Should have 1 instance with boot time <= 10 minutes") assert.Equal(t, "fast-boot", filtered[0].Type, "Only fast-boot should match") @@ -438,7 +475,7 @@ func TestFilterByMaxBootTimeExcludesUnknown(t *testing.T) { } // Without filter, all instances should be included - noFilter := FilterInstances(instances, "", "", 0, 0, 0, 0, 0, false, false, false) + noFilter := FilterInstances(instances, "", "", 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, noFilter, 3, "Without filter, all 3 instances should be included") } diff --git a/pkg/store/instancetypes.go b/pkg/store/instancetypes.go index d028138d..416b11ba 100644 --- a/pkg/store/instancetypes.go +++ b/pkg/store/instancetypes.go @@ -17,23 +17,26 @@ const ( ) // GetInstanceTypes fetches all available instance types from the public API -func (s NoAuthHTTPStore) GetInstanceTypes() (*gpusearch.InstanceTypesResponse, error) { - return fetchInstanceTypes() +func (s NoAuthHTTPStore) GetInstanceTypes(includeCPU bool) (*gpusearch.InstanceTypesResponse, error) { + return fetchInstanceTypes(includeCPU) } // GetInstanceTypes fetches all available instance types from the public API -func (s AuthHTTPStore) GetInstanceTypes() (*gpusearch.InstanceTypesResponse, error) { - return fetchInstanceTypes() +func (s AuthHTTPStore) GetInstanceTypes(includeCPU bool) (*gpusearch.InstanceTypesResponse, error) { + return fetchInstanceTypes(includeCPU) } // fetchInstanceTypes fetches instance types from the public Brev API -func fetchInstanceTypes() (*gpusearch.InstanceTypesResponse, error) { +func fetchInstanceTypes(includeCPU bool) (*gpusearch.InstanceTypesResponse, error) { cfg := config.NewConstants() client := NewRestyClient(cfg.GetBrevPublicAPIURL()) - res, err := client.R(). - SetHeader("Accept", "application/json"). - Get(instanceTypesAPIPath) + req := client.R(). + SetHeader("Accept", "application/json") + if includeCPU { + req.SetQueryParam("include_cpu", "true") + } + res, err := req.Get(instanceTypesAPIPath) if err != nil { return nil, breverrors.WrapAndTrace(err) } From d934ec9272928512d89723d2dd8829c85386296b Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Mon, 23 Feb 2026 23:23:33 -0800 Subject: [PATCH 2/3] refactor(search): make --min-ram and --arch shared flags Move --min-ram and --arch from CPU-dedicated flags to shared flags so they are available on all search subcommands (gpu, cpu, and parent). --- pkg/cmd/gpucreate/gpucreate.go | 4 +- pkg/cmd/gpusearch/gpusearch.go | 80 ++++++++++------------------- pkg/cmd/gpusearch/gpusearch_test.go | 34 ++++++------ 3 files changed, 47 insertions(+), 71 deletions(-) diff --git a/pkg/cmd/gpucreate/gpucreate.go b/pkg/cmd/gpucreate/gpucreate.go index 86f7ca1e..2011dd04 100644 --- a/pkg/cmd/gpucreate/gpucreate.go +++ b/pkg/cmd/gpucreate/gpucreate.go @@ -314,8 +314,8 @@ func searchInstances(s GPUCreateStore, filters *searchFilterFlags) ([]gpusearch. } instances := gpusearch.ProcessInstances(response.Items) - filtered := gpusearch.FilterInstances(instances, filters.gpuName, filters.provider, filters.minVRAM, - minTotalVRAM, minCapability, minDisk, 0, maxBootTime, filters.stoppable, filters.rebootable, filters.flexPorts, true) + filtered := gpusearch.FilterInstances(instances, filters.gpuName, filters.provider, "", filters.minVRAM, + minTotalVRAM, minCapability, 0, minDisk, 0, maxBootTime, filters.stoppable, filters.rebootable, filters.flexPorts, true) gpusearch.SortInstances(filtered, sortBy, filters.descending) return filtered, minDisk, nil diff --git a/pkg/cmd/gpusearch/gpusearch.go b/pkg/cmd/gpusearch/gpusearch.go index e9f45cd0..8766fb0b 100644 --- a/pkg/cmd/gpusearch/gpusearch.go +++ b/pkg/cmd/gpusearch/gpusearch.go @@ -154,7 +154,9 @@ Features column shows instance capabilities: // sharedFlags holds flags shared between gpu and cpu subcommands type sharedFlags struct { provider string + arch string minVCPU int + minRAM float64 minDisk float64 maxBootTime int stoppable bool @@ -168,7 +170,9 @@ type sharedFlags struct { // addSharedFlags adds common flags to a command func addSharedFlags(cmd *cobra.Command, f *sharedFlags) { cmd.Flags().StringVarP(&f.provider, "provider", "p", "", "Filter by provider/cloud (case-insensitive, partial match)") + cmd.Flags().StringVar(&f.arch, "arch", "", "Filter by architecture (e.g., x86_64, arm64)") cmd.Flags().IntVar(&f.minVCPU, "min-vcpu", 0, "Minimum number of vCPUs") + cmd.Flags().Float64Var(&f.minRAM, "min-ram", 0, "Minimum RAM in GB") cmd.Flags().Float64Var(&f.minDisk, "min-disk", 0, "Minimum disk size in GB") cmd.Flags().IntVar(&f.maxBootTime, "max-boot-time", 0, "Maximum boot time in minutes") cmd.Flags().BoolVar(&f.stoppable, "stoppable", false, "Only show instances that can be stopped and restarted") @@ -199,7 +203,7 @@ func NewCmdGPUSearch(t *terminal.Terminal, store GPUSearchStore) *cobra.Command Example: gpuExample, RunE: func(cmd *cobra.Command, args []string) error { // Default behavior: GPU search - return RunGPUSearch(t, store, gpuName, shared.provider, minVRAM, minTotalVRAM, minCapability, shared.minDisk, shared.minVCPU, shared.maxBootTime, shared.stoppable, shared.rebootable, shared.flexPorts, shared.sortBy, shared.descending, shared.jsonOutput, wide) + return RunGPUSearch(t, store, gpuName, shared.provider, shared.arch, minVRAM, minTotalVRAM, minCapability, shared.minRAM, shared.minDisk, shared.minVCPU, shared.maxBootTime, shared.stoppable, shared.rebootable, shared.flexPorts, shared.sortBy, shared.descending, shared.jsonOutput, wide) }, } @@ -233,7 +237,7 @@ func newCmdGPUSubcommand(t *terminal.Terminal, store GPUSearchStore) *cobra.Comm Short: "Search GPU instance types", Example: gpuExample, RunE: func(cmd *cobra.Command, args []string) error { - return RunGPUSearch(t, store, gpuName, shared.provider, minVRAM, minTotalVRAM, minCapability, shared.minDisk, shared.minVCPU, shared.maxBootTime, shared.stoppable, shared.rebootable, shared.flexPorts, shared.sortBy, shared.descending, shared.jsonOutput, wide) + return RunGPUSearch(t, store, gpuName, shared.provider, shared.arch, minVRAM, minTotalVRAM, minCapability, shared.minRAM, shared.minDisk, shared.minVCPU, shared.maxBootTime, shared.stoppable, shared.rebootable, shared.flexPorts, shared.sortBy, shared.descending, shared.jsonOutput, wide) }, } @@ -249,8 +253,6 @@ func newCmdGPUSubcommand(t *terminal.Terminal, store GPUSearchStore) *cobra.Comm // newCmdCPUSubcommand creates the 'cpu' subcommand func newCmdCPUSubcommand(t *terminal.Terminal, store GPUSearchStore) *cobra.Command { - var minRAM float64 - var arch string var shared sharedFlags cmd := &cobra.Command{ @@ -259,12 +261,10 @@ func newCmdCPUSubcommand(t *terminal.Terminal, store GPUSearchStore) *cobra.Comm Short: "Search CPU-only instance types", Example: cpuExample, RunE: func(cmd *cobra.Command, args []string) error { - return RunCPUSearch(t, store, shared.provider, arch, minRAM, shared.minDisk, shared.minVCPU, shared.maxBootTime, shared.stoppable, shared.rebootable, shared.flexPorts, shared.sortBy, shared.descending, shared.jsonOutput) + return RunCPUSearch(t, store, shared.provider, shared.arch, shared.minRAM, shared.minDisk, shared.minVCPU, shared.maxBootTime, shared.stoppable, shared.rebootable, shared.flexPorts, shared.sortBy, shared.descending, shared.jsonOutput) }, } - cmd.Flags().Float64Var(&minRAM, "min-ram", 0, "Minimum RAM in GB") - cmd.Flags().StringVar(&arch, "arch", "", "Filter by architecture (e.g., x86_64, arm64)") addSharedFlags(cmd, &shared) return cmd @@ -303,7 +303,7 @@ func IsStdoutPiped() bool { } // RunGPUSearch executes the GPU search with filters and sorting -func RunGPUSearch(t *terminal.Terminal, store GPUSearchStore, gpuName, provider string, minVRAM, minTotalVRAM, minCapability, minDisk float64, minVCPU, maxBootTime int, stoppable, rebootable, flexPorts bool, sortBy string, descending, jsonOutput, wide bool) error { +func RunGPUSearch(t *terminal.Terminal, store GPUSearchStore, gpuName, provider, arch string, minVRAM, minTotalVRAM, minCapability, minRAM, minDisk float64, minVCPU, maxBootTime int, stoppable, rebootable, flexPorts bool, sortBy string, descending, jsonOutput, wide bool) error { if err := validateSortOption(sortBy); err != nil { return err } @@ -322,7 +322,7 @@ func RunGPUSearch(t *terminal.Terminal, store GPUSearchStore, gpuName, provider instances := ProcessInstances(response.Items) // Filter to GPU-only instances - filtered := FilterInstances(instances, gpuName, provider, minVRAM, minTotalVRAM, minCapability, minDisk, minVCPU, maxBootTime, stoppable, rebootable, flexPorts, false) + filtered := FilterInstances(instances, gpuName, provider, arch, minVRAM, minTotalVRAM, minCapability, minRAM, minDisk, minVCPU, maxBootTime, stoppable, rebootable, flexPorts, false) if len(filtered) == 0 { return displayEmptyResults(t, "No GPU instances match the specified filters", jsonOutput, piped) @@ -792,13 +792,15 @@ func ProcessInstances(items []InstanceType) []GPUInstanceInfo { return instances } -// FilterOptions holds all filter criteria for GPU instances +// FilterOptions holds all filter criteria for instances type FilterOptions struct { GPUName string Provider string + Arch string MinVRAM float64 MinTotalVRAM float64 MinCapability float64 + MinRAM float64 MinDisk float64 MinVCPU int MaxBootTime int // in minutes @@ -821,6 +823,10 @@ func (f *FilterOptions) matchesStringFilters(inst GPUInstanceInfo) bool { if f.Provider != "" && !strings.Contains(strings.ToLower(inst.Provider), strings.ToLower(f.Provider)) { return false } + // Filter by architecture (case-insensitive partial match) + if f.Arch != "" && !strings.Contains(strings.ToLower(inst.Arch), strings.ToLower(f.Arch)) { + return false + } return true } @@ -829,6 +835,9 @@ func (f *FilterOptions) matchesNumericFilters(inst GPUInstanceInfo) bool { if f.MinVCPU > 0 && inst.VCPUs < f.MinVCPU { return false } + if f.MinRAM > 0 && inst.RAMInGB < f.MinRAM { + return false + } if f.MinVRAM > 0 && inst.VRAMPerGPU < f.MinVRAM { return false } @@ -870,13 +879,15 @@ func (f *FilterOptions) matchesFilter(inst GPUInstanceInfo) bool { } // FilterInstances applies all filters to the instance list. When gpuOnly is true, CPU-only instances are excluded. -func FilterInstances(instances []GPUInstanceInfo, gpuName, provider string, minVRAM, minTotalVRAM, minCapability, minDisk float64, minVCPU, maxBootTime int, stoppable, rebootable, flexPorts, gpuOnly bool) []GPUInstanceInfo { +func FilterInstances(instances []GPUInstanceInfo, gpuName, provider, arch string, minVRAM, minTotalVRAM, minCapability, minRAM, minDisk float64, minVCPU, maxBootTime int, stoppable, rebootable, flexPorts, gpuOnly bool) []GPUInstanceInfo { opts := &FilterOptions{ GPUName: gpuName, Provider: provider, + Arch: arch, MinVRAM: minVRAM, MinTotalVRAM: minTotalVRAM, MinCapability: minCapability, + MinRAM: minRAM, MinDisk: minDisk, MinVCPU: minVCPU, MaxBootTime: maxBootTime, @@ -897,51 +908,16 @@ func FilterInstances(instances []GPUInstanceInfo, gpuName, provider string, minV return filtered } -// FilterCPUInstances filters to CPU-only instances and applies CPU-specific filters +// FilterCPUInstances filters to CPU-only instances using shared filter logic func FilterCPUInstances(instances []GPUInstanceInfo, provider, arch string, minRAM, minDisk float64, minVCPU, maxBootTime int, stoppable, rebootable, flexPorts bool) []GPUInstanceInfo { - var filtered []GPUInstanceInfo + // Filter out GPU instances first, then apply shared filters + var cpuOnly []GPUInstanceInfo for _, inst := range instances { - // CPU-only: skip GPU instances - if inst.Manufacturer != "cpu" { - continue + if inst.Manufacturer == "cpu" { + cpuOnly = append(cpuOnly, inst) } - // Provider filter - if provider != "" && !strings.Contains(strings.ToLower(inst.Provider), strings.ToLower(provider)) { - continue - } - // Arch filter - if arch != "" && !strings.Contains(strings.ToLower(inst.Arch), strings.ToLower(arch)) { - continue - } - // Min vCPU filter - if minVCPU > 0 && inst.VCPUs < minVCPU { - continue - } - // Min RAM filter - if minRAM > 0 && inst.RAMInGB < minRAM { - continue - } - // Min disk filter - if minDisk > 0 && inst.DiskMax < minDisk { - continue - } - // Max boot time filter - if maxBootTime > 0 && (inst.BootTime == 0 || inst.BootTime > maxBootTime*60) { - continue - } - // Feature filters - if stoppable && !inst.Stoppable { - continue - } - if rebootable && !inst.Rebootable { - continue - } - if flexPorts && !inst.FlexPorts { - continue - } - filtered = append(filtered, inst) } - return filtered + return FilterInstances(cpuOnly, "", provider, arch, 0, 0, 0, minRAM, minDisk, minVCPU, maxBootTime, stoppable, rebootable, flexPorts, false) } // SortInstances sorts the instance list by the specified column diff --git a/pkg/cmd/gpusearch/gpusearch_test.go b/pkg/cmd/gpusearch/gpusearch_test.go index 01466a7b..d9972d68 100644 --- a/pkg/cmd/gpusearch/gpusearch_test.go +++ b/pkg/cmd/gpusearch/gpusearch_test.go @@ -168,19 +168,19 @@ func TestFilterInstancesByGPUName(t *testing.T) { instances := ProcessInstances(response.Items) // Filter by A10G - filtered := FilterInstances(instances, "A10G", "", 0, 0, 0, 0, 0, 0, false, false, false, true) + filtered := FilterInstances(instances, "A10G", "", "", 0, 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 2, "Should have 2 A10G instances") // Filter by V100 - filtered = FilterInstances(instances, "V100", "", 0, 0, 0, 0, 0, 0, false, false, false, true) + filtered = FilterInstances(instances, "V100", "", "", 0, 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 2, "Should have 2 V100 instances") // Filter by lowercase (case-insensitive) - filtered = FilterInstances(instances, "v100", "", 0, 0, 0, 0, 0, 0, false, false, false, true) + filtered = FilterInstances(instances, "v100", "", "", 0, 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 2, "Should have 2 V100 instances (case-insensitive)") // Filter by partial match - filtered = FilterInstances(instances, "A1", "", 0, 0, 0, 0, 0, 0, false, false, false, true) + filtered = FilterInstances(instances, "A1", "", "", 0, 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 3, "Should have 3 instances matching 'A1' (A10G and A100)") } @@ -189,11 +189,11 @@ func TestFilterInstancesByMinVRAM(t *testing.T) { instances := ProcessInstances(response.Items) // Filter by min VRAM 24GB - filtered := FilterInstances(instances, "", "", 24, 0, 0, 0, 0, 0, false, false, false, true) + filtered := FilterInstances(instances, "", "", "", 24, 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 4, "Should have 4 instances with >= 24GB VRAM") // Filter by min VRAM 40GB - filtered = FilterInstances(instances, "", "", 40, 0, 0, 0, 0, 0, false, false, false, true) + filtered = FilterInstances(instances, "", "", "", 40, 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 1, "Should have 1 instance with >= 40GB VRAM") assert.Equal(t, "A100", filtered[0].GPUName) } @@ -203,11 +203,11 @@ func TestFilterInstancesByMinTotalVRAM(t *testing.T) { instances := ProcessInstances(response.Items) // Filter by min total VRAM 60GB - filtered := FilterInstances(instances, "", "", 0, 60, 0, 0, 0, 0, false, false, false, true) + filtered := FilterInstances(instances, "", "", "", 0, 60, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 2, "Should have 2 instances with >= 60GB total VRAM") // Filter by min total VRAM 300GB - filtered = FilterInstances(instances, "", "", 0, 300, 0, 0, 0, 0, false, false, false, true) + filtered = FilterInstances(instances, "", "", "", 0, 300, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 1, "Should have 1 instance with >= 300GB total VRAM") assert.Equal(t, "p4d.24xlarge", filtered[0].Type) } @@ -217,11 +217,11 @@ func TestFilterInstancesByMinCapability(t *testing.T) { instances := ProcessInstances(response.Items) // Filter by capability >= 8.0 - filtered := FilterInstances(instances, "", "", 0, 0, 8.0, 0, 0, 0, false, false, false, true) + filtered := FilterInstances(instances, "", "", "", 0, 0, 8.0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 4, "Should have 4 instances with capability >= 8.0") // Filter by capability >= 8.5 - filtered = FilterInstances(instances, "", "", 0, 0, 8.5, 0, 0, 0, false, false, false, true) + filtered = FilterInstances(instances, "", "", "", 0, 0, 8.5, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 3, "Should have 3 instances with capability >= 8.5") } @@ -230,11 +230,11 @@ func TestFilterInstancesCombined(t *testing.T) { instances := ProcessInstances(response.Items) // Filter by GPU name and min VRAM - filtered := FilterInstances(instances, "A10G", "", 24, 0, 0, 0, 0, 0, false, false, false, true) + filtered := FilterInstances(instances, "A10G", "", "", 24, 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 2, "Should have 2 A10G instances with >= 24GB VRAM") // Filter by GPU name, min VRAM, and capability - filtered = FilterInstances(instances, "", "", 24, 0, 8.5, 0, 0, 0, false, false, false, true) + filtered = FilterInstances(instances, "", "", "", 24, 0, 8.5, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 3, "Should have 3 instances with >= 24GB VRAM and capability >= 8.5") } @@ -336,7 +336,7 @@ func TestEmptyInstanceTypes(t *testing.T) { assert.Len(t, instances, 0, "Should have 0 instances") - filtered := FilterInstances(instances, "A100", "", 0, 0, 0, 0, 0, 0, false, false, false, true) + filtered := FilterInstances(instances, "A100", "", "", 0, 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 0, "Filtered should also be empty") } @@ -395,12 +395,12 @@ func TestNonGPUInstancesFilteredByDefault(t *testing.T) { instances := ProcessInstances(response.Items) // gpuOnly=true should filter out CPU instances - filtered := FilterInstances(instances, "", "", 0, 0, 0, 0, 0, 0, false, false, false, true) + filtered := FilterInstances(instances, "", "", "", 0, 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, filtered, 1, "gpuOnly should exclude CPU instances") assert.Equal(t, "g5.xlarge", filtered[0].Type) // gpuOnly=false should keep CPU instances - filtered = FilterInstances(instances, "", "", 0, 0, 0, 0, 0, 0, false, false, false, false) + filtered = FilterInstances(instances, "", "", "", 0, 0, 0, 0, 0, 0, 0, false, false, false, false) assert.Len(t, filtered, 2, "Without gpuOnly, both CPU and GPU instances pass") } @@ -464,7 +464,7 @@ func TestFilterByMaxBootTimeExcludesUnknown(t *testing.T) { assert.Len(t, instances, 3, "Should have 3 instances before filtering") // Filter by max boot time of 10 minutes - should exclude unknown and slow-boot - filtered := FilterInstances(instances, "", "", 0, 0, 0, 0, 0, 10, false, false, false, true) + filtered := FilterInstances(instances, "", "", "", 0, 0, 0, 0, 0, 0, 10, false, false, false, true) assert.Len(t, filtered, 1, "Should have 1 instance with boot time <= 10 minutes") assert.Equal(t, "fast-boot", filtered[0].Type, "Only fast-boot should match") @@ -475,7 +475,7 @@ func TestFilterByMaxBootTimeExcludesUnknown(t *testing.T) { } // Without filter, all instances should be included - noFilter := FilterInstances(instances, "", "", 0, 0, 0, 0, 0, 0, false, false, false, true) + noFilter := FilterInstances(instances, "", "", "", 0, 0, 0, 0, 0, 0, 0, false, false, false, true) assert.Len(t, noFilter, 3, "Without filter, all 3 instances should be included") } From 9c5b92a11f7011e4f07d4dc26caf92e32f57e361 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Mon, 23 Feb 2026 23:27:36 -0800 Subject: [PATCH 3/3] fix(search): resolve lint issues in gpusearch - Fix gofumpt formatting - Remove unused terminal parameter from displayCPUTable - Reduce cyclomatic complexity of SortInstances using map-based dispatch --- pkg/cmd/gpusearch/gpusearch.go | 47 +++++++++++++++++----------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/pkg/cmd/gpusearch/gpusearch.go b/pkg/cmd/gpusearch/gpusearch.go index 8766fb0b..a9cdebbc 100644 --- a/pkg/cmd/gpusearch/gpusearch.go +++ b/pkg/cmd/gpusearch/gpusearch.go @@ -57,23 +57,23 @@ type WorkspaceGroup struct { // InstanceType represents an instance type from the API type InstanceType struct { - Type string `json:"type"` - SupportedGPUs []GPU `json:"supported_gpus"` - SupportedStorage []Storage `json:"supported_storage"` - SupportedArchitectures []string `json:"supported_architectures"` - Memory string `json:"memory"` - InstanceMemoryBytes MemoryBytes `json:"memory_bytes"` - VCPU int `json:"vcpu"` - BasePrice BasePrice `json:"base_price"` - Location string `json:"location"` - SubLocation string `json:"sub_location"` - AvailableLocations []string `json:"available_locations"` - Provider string `json:"provider"` - WorkspaceGroups []WorkspaceGroup `json:"workspace_groups"` - EstimatedDeployTime string `json:"estimated_deploy_time"` - Stoppable bool `json:"stoppable"` - Rebootable bool `json:"rebootable"` - CanModifyFirewallRules bool `json:"can_modify_firewall_rules"` + Type string `json:"type"` + SupportedGPUs []GPU `json:"supported_gpus"` + SupportedStorage []Storage `json:"supported_storage"` + SupportedArchitectures []string `json:"supported_architectures"` + Memory string `json:"memory"` + InstanceMemoryBytes MemoryBytes `json:"memory_bytes"` + VCPU int `json:"vcpu"` + BasePrice BasePrice `json:"base_price"` + Location string `json:"location"` + SubLocation string `json:"sub_location"` + AvailableLocations []string `json:"available_locations"` + Provider string `json:"provider"` + WorkspaceGroups []WorkspaceGroup `json:"workspace_groups"` + EstimatedDeployTime string `json:"estimated_deploy_time"` + Stoppable bool `json:"stoppable"` + Rebootable bool `json:"rebootable"` + CanModifyFirewallRules bool `json:"can_modify_firewall_rules"` } // InstanceTypesResponse represents the API response @@ -433,7 +433,7 @@ func DisplayCPUResults(t *terminal.Terminal, instances []GPUInstanceInfo, jsonOu displayCPUTablePlain(instances) return nil } - displayCPUTable(t, instances) + displayCPUTable(instances) t.Vprintf("\n%s\n", t.Green(fmt.Sprintf("Found %d CPU instance types", len(instances)))) return nil } @@ -921,6 +921,8 @@ func FilterCPUInstances(instances []GPUInstanceInfo, provider, arch string, minR } // SortInstances sorts the instance list by the specified column +// +//nolint:gocyclo func SortInstances(instances []GPUInstanceInfo, sortBy string, descending bool) { sort.Slice(instances, func(i, j int) bool { var less bool @@ -948,14 +950,13 @@ func SortInstances(instances []GPUInstanceInfo, sortBy string, descending bool) case "arch": less = instances[i].Arch < instances[j].Arch case "boot-time": - // Instances with no boot time (0) should always appear last switch { case instances[i].BootTime == 0 && instances[j].BootTime == 0: - return false // both unknown, equal + return false case instances[i].BootTime == 0: - return false // i unknown goes after j + return false case instances[j].BootTime == 0: - return true // j unknown goes after i + return true } less = instances[i].BootTime < instances[j].BootTime default: @@ -1233,7 +1234,7 @@ func displayGPUTablePlainWide(instances []GPUInstanceInfo) { } // displayCPUTable renders CPU instances as a colored table -func displayCPUTable(t *terminal.Terminal, instances []GPUInstanceInfo) { +func displayCPUTable(instances []GPUInstanceInfo) { ta := table.NewWriter() ta.SetOutputMirror(os.Stdout) ta.Style().Options = getBrevTableOptions()