diff --git a/.agents/skills/brev-cli/SKILL.md b/.agents/skills/brev-cli/SKILL.md index 6e6c973e..7e6f605e 100644 --- a/.agents/skills/brev-cli/SKILL.md +++ b/.agents/skills/brev-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: brev-cli -description: Manage GPU cloud instances with the Brev CLI. Use when users want to create GPU instances, search for GPUs, SSH into instances, open editors, copy files, port forward, manage organizations, or work with cloud compute. Trigger keywords - brev, gpu, instance, create instance, ssh, vram, A100, H100, cloud gpu, remote machine. +description: Manage GPU and CPU cloud instances with the Brev CLI. Use when users want to create instances, search for GPUs or CPUs, SSH into instances, open editors, copy files, port forward, manage organizations, or work with cloud compute. Trigger keywords - brev, gpu, cpu, instance, create instance, ssh, vram, vcpu, A100, H100, cloud gpu, cloud cpu, remote machine. allowed-tools: Bash, Read, AskUserQuestion argument-hint: [create|search|shell|exec|open|ls|delete] [instance-name] --- @@ -13,20 +13,21 @@ Token Budget: # Brev CLI -Manage GPU cloud instances from the command line. Create, search, connect, and manage remote GPU machines. +Manage GPU and CPU cloud instances from the command line. Create, search, connect, and manage remote machines. ## When to Use Use this skill when users want to: -- Create GPU instances (with smart defaults or specific types) +- Create GPU or CPU instances (with smart defaults or specific types) - Search for available GPU types (A100, H100, L40S, etc.) +- Search for CPU-only instance types (no GPU) - SSH into instances or run commands remotely - Open editors (VS Code, Cursor, Windsurf) on remote instances - Copy files to/from instances - Port forward from remote to local - Manage organizations and instances -**Trigger Keywords:** brev, gpu, instance, create instance, ssh, vram, A100, H100, cloud gpu, remote machine, shell +**Trigger Keywords:** brev, gpu, cpu, instance, create instance, ssh, vram, vcpu, A100, H100, cloud gpu, cloud cpu, remote machine, shell ## Quick Start @@ -34,10 +35,13 @@ Use this skill when users want to: # Search for GPUs (sorted by price) brev search +# Search for CPU-only instances +brev search cpu + # Create an instance with smart defaults brev create my-instance -# Create with specific GPU +# Create with specific type brev create my-instance --type g5.xlarge # List your instances @@ -58,8 +62,12 @@ brev open my-instance cursor ### Search GPUs ```bash -# All available GPUs +# All available GPUs (default) brev search +brev search gpu + +# GPU search with wide mode (shows RAM and ARCH columns) +brev search gpu --wide # Filter by GPU name brev search --gpu-name A100 @@ -75,6 +83,32 @@ brev search --max-boot-time 5 --sort price brev search --stoppable --min-total-vram 40 --sort price ``` +### Search CPUs +```bash +# All available CPU-only instances +brev search cpu + +# Filter by provider +brev search cpu --provider aws + +# Filter by minimum RAM +brev search cpu --min-ram 64 + +# Filter by architecture +brev search cpu --arch arm64 + +# Filter by vCPUs +brev search cpu --min-vcpu 16 + +# Sort by price +brev search cpu --sort price + +# JSON output +brev search cpu --json +``` + +CPU search shows: TYPE, PROVIDER, VCPUs, RAM, ARCH, DISK, $/GB/MO, BOOT, FEATURES, $/HR + ### Create Instances ```bash # Smart defaults (cheapest matching GPU) @@ -234,6 +268,6 @@ brev invite ## References - **[reference/commands.md](reference/commands.md)** - Full command reference -- **[reference/search-filters.md](reference/search-filters.md)** - GPU search options +- **[reference/search-filters.md](reference/search-filters.md)** - GPU and CPU search options - **[prompts/](prompts/)** - Workflow guides - **[examples/](examples/)** - Common patterns diff --git a/.agents/skills/brev-cli/examples/common-patterns.md b/.agents/skills/brev-cli/examples/common-patterns.md index 51f76128..598807cd 100644 --- a/.agents/skills/brev-cli/examples/common-patterns.md +++ b/.agents/skills/brev-cli/examples/common-patterns.md @@ -134,6 +134,44 @@ git config --global user.name "Your Name" git config --global user.email "you@example.com" ``` +## CPU Instance Patterns + +### Find CPU Instances +```bash +# All CPU instances sorted by price +brev search cpu --sort price + +# Cheapest ARM CPU instances +brev search cpu --arch arm64 --sort price + +# High-memory CPU for data processing +brev search cpu --min-ram 128 --sort price + +# Many-core for parallel workloads +brev search cpu --min-vcpu 32 --sort price +``` + +### Create CPU Instance +```bash +# Create from CPU search +brev search cpu --min-ram 64 | brev create my-cpu-box + +# Create CPU instance and run setup +brev search cpu --sort price | brev create data-proc | brev exec @setup.sh +``` + +### CPU Use Cases +```bash +# Data preprocessing box +brev search cpu --min-ram 64 --min-disk 500 | brev create etl-box + +# CI/CD runner +brev search cpu --min-vcpu 8 --max-boot-time 3 | brev create ci-runner + +# Web server / API host +brev search cpu --stoppable --sort price | brev create api-server +``` + ## Quick Start Patterns ### Create and Connect (One-Liner) diff --git a/.agents/skills/brev-cli/reference/commands.md b/.agents/skills/brev-cli/reference/commands.md index f55739b0..978de983 100644 --- a/.agents/skills/brev-cli/reference/commands.md +++ b/.agents/skills/brev-cli/reference/commands.md @@ -131,15 +131,22 @@ brev create my-instance --dry-run ``` ### brev search -Search and filter available GPU instance types. +Search and filter available instance types. Has two subcommands: `gpu` (default) and `cpu`. ```bash -brev search [flags] +brev search [gpu|cpu] [flags] ``` **Aliases:** `gpu-search`, `gpu`, `gpus`, `gpu-list` -**Flags:** +#### GPU Search (default) +```bash +brev search [flags] +brev search gpu [flags] +brev search gpu --wide # shows RAM and ARCH columns +``` + +**GPU Flags:** | Flag | Short | Description | |------|-------|-------------| | `--gpu-name` | `-g` | Filter by GPU name (partial match) | @@ -155,16 +162,51 @@ brev search [flags] | `--sort` | `-s` | Sort by: price, gpu-count, vram, total-vram, vcpu, disk, boot-time | | `--desc` | `-d` | Sort descending | | `--json` | | Output as JSON | +| `--wide` | `-w` | Show extra columns (RAM, ARCH) — gpu subcommand only | -**Examples:** +**GPU Examples:** ```bash brev search +brev search gpu --wide brev search --gpu-name A100 brev search --min-vram 40 --sort price brev search --gpu-name H100 --max-boot-time 3 brev search --stoppable --min-total-vram 40 --sort price ``` +#### CPU Search +```bash +brev search cpu [flags] +``` + +Search for CPU-only instance types (no GPU). Uses shared flags only. + +**CPU Flags:** +| Flag | Short | Description | +|------|-------|-------------| +| `--provider` | `-p` | Filter by cloud provider | +| `--arch` | | Filter by architecture (x86_64, arm64) | +| `--min-ram` | | Minimum RAM in GB | +| `--min-disk` | | Minimum disk size (GB) | +| `--min-vcpu` | | Minimum number of vCPUs | +| `--max-boot-time` | | Maximum boot time (minutes) | +| `--stoppable` | | Only stoppable instances | +| `--rebootable` | | Only rebootable instances | +| `--flex-ports` | | Only instances with configurable firewall | +| `--sort` | `-s` | Sort by: price, vcpu, type, provider, disk, boot-time | +| `--desc` | `-d` | Sort descending | +| `--json` | | Output as JSON | + +**CPU Examples:** +```bash +brev search cpu +brev search cpu --provider aws +brev search cpu --min-ram 64 --sort price +brev search cpu --arch arm64 +brev search cpu --min-vcpu 16 --sort price +brev search cpu | brev create my-cpu-box +``` + ### brev ls List instances in active org. diff --git a/.agents/skills/brev-cli/reference/search-filters.md b/.agents/skills/brev-cli/reference/search-filters.md index 75deda83..8c418c37 100644 --- a/.agents/skills/brev-cli/reference/search-filters.md +++ b/.agents/skills/brev-cli/reference/search-filters.md @@ -1,6 +1,13 @@ -# GPU Search Filters Reference +# Search Filters Reference -Detailed guide to filtering and sorting GPU instance types. +Detailed guide to filtering and sorting GPU and CPU instance types. + +## Command Structure + +`brev search` has two subcommands: +- `brev search` or `brev search gpu` — GPU instances (default, backwards compatible) +- `brev search gpu --wide` — GPU instances with extra RAM and ARCH columns +- `brev search cpu` — CPU-only instances (no GPU) ## Filter Options @@ -187,3 +194,64 @@ brev search --json | jq '.[] | {type, gpu_name, price}' | S | Stoppable - can stop/restart without data loss | | R | Rebootable - can reboot the instance | | P | Flex Ports - can modify firewall rules | + +## CPU Search (`brev search cpu`) + +CPU search shows instances without GPUs. It uses shared flags (no GPU-specific flags like `--gpu-name`, `--min-vram`, etc.). + +### CPU Filter Options + +| Flag | Short | Description | +|------|-------|-------------| +| `--provider` | `-p` | Filter by cloud provider | +| `--arch` | | Filter by architecture (x86_64, arm64) | +| `--min-ram` | | Minimum RAM in GB | +| `--min-disk` | | Minimum disk size in GB | +| `--min-vcpu` | | Minimum number of vCPUs | +| `--max-boot-time` | | Maximum boot time in minutes | +| `--stoppable` | | Only stoppable instances | +| `--rebootable` | | Only rebootable instances | +| `--flex-ports` | | Only instances with configurable firewall | +| `--sort` | `-s` | Sort column (price, vcpu, type, provider, disk, boot-time) | +| `--desc` | `-d` | Sort descending | +| `--json` | | Output as JSON | + +### CPU Output Columns + +**Interactive (terminal):** +``` +TYPE | PROVIDER | VCPUs | RAM | ARCH | DISK | $/GB/MO | BOOT | FEATURES | $/HR +``` + +**Piped (stdout):** +``` +TYPE | TARGET_DISK | PROVIDER | VCPUs | RAM | ARCH | DISK | $/GB/MO | BOOT | FEATURES | $/HR +``` + +### CPU Filter Examples + +```bash +# All CPU instances +brev search cpu + +# Cheap ARM instances +brev search cpu --arch arm64 --sort price + +# High-memory instances for data processing +brev search cpu --min-ram 128 --sort price + +# Many-core instances for parallel workloads +brev search cpu --min-vcpu 32 --sort price + +# Fast-booting CPU instances +brev search cpu --max-boot-time 3 --sort price + +# Stoppable CPU instances (save costs) +brev search cpu --stoppable --sort price + +# AWS CPU instances with large disk +brev search cpu --provider aws --min-disk 500 + +# Pipe CPU search into create +brev search cpu --min-ram 64 | brev create my-cpu-box +``` diff --git a/pkg/analytics/posthog.go b/pkg/analytics/posthog.go index 9d7b50cd..5743035c 100644 --- a/pkg/analytics/posthog.go +++ b/pkg/analytics/posthog.go @@ -319,9 +319,24 @@ func getTimezone() string { } func getGPUInfo() string { + type result struct { + out string + } + ch := make(chan result, 1) + go func() { + ch <- result{out: getGPUInfoSync()} + }() + select { + case r := <-ch: + return r.out + case <-time.After(100 * time.Millisecond): + return "" + } +} + +func getGPUInfoSync() string { out, err := exec.Command("nvidia-smi", "--query-gpu=name,memory.total,driver_version,count", "--format=csv,noheader,nounits").Output() // #nosec G204 if err != nil { - // nvidia-smi not available or no NVIDIA GPU if runtime.GOOS == "darwin" { return getAppleGPUInfo() } @@ -335,7 +350,6 @@ func getAppleGPUInfo() string { if err != nil { return "" } - // Extract just the chipset/model lines lines := strings.Split(string(out), "\n") var gpuLines []string for _, line := range lines { diff --git a/pkg/cmd/gpucreate/gpucreate.go b/pkg/cmd/gpucreate/gpucreate.go index 6f601a3b..b58c7b94 100644 --- a/pkg/cmd/gpucreate/gpucreate.go +++ b/pkg/cmd/gpucreate/gpucreate.go @@ -318,7 +318,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) } @@ -340,8 +340,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, maxBootTime, filters.stoppable, filters.rebootable, filters.flexPorts) + 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 @@ -375,7 +375,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 4bc5e897..612b26a4 100644 --- a/pkg/cmd/gpusearch/gpusearch.go +++ b/pkg/cmd/gpusearch/gpusearch.go @@ -60,7 +60,9 @@ 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"` @@ -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 + arch string + minVCPU int + minRAM float64 + 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().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") + 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{}, 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, 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) }, } + // 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().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) + + // 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, 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) + }, + } + + 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) + + return cmd +} + +// newCmdCPUSubcommand creates the 'cpu' subcommand +func newCmdCPUSubcommand(t *terminal.Terminal, store GPUSearchStore) *cobra.Command { + 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, shared.arch, shared.minRAM, shared.minDisk, shared.minVCPU, shared.maxBootTime, shared.stoppable, shared.rebootable, shared.flexPorts, shared.sortBy, shared.descending, shared.jsonOutput) + }, + } + + 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, 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 } - // 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, 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) } - // 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(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, @@ -635,14 +792,17 @@ 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 Stoppable bool Rebootable bool @@ -651,8 +811,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) @@ -663,11 +823,21 @@ 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 } -// 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.MinRAM > 0 && inst.RAMInGB < f.MinRAM { + return false + } if f.MinVRAM > 0 && inst.VRAMPerGPU < f.MinVRAM { return false } @@ -708,15 +878,18 @@ 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, 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, Stoppable: stoppable, Rebootable: rebootable, @@ -725,6 +898,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,7 +908,21 @@ func FilterInstances(instances []GPUInstanceInfo, gpuName, provider string, minV return filtered } +// 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 { + // Filter out GPU instances first, then apply shared filters + var cpuOnly []GPUInstanceInfo + for _, inst := range instances { + if inst.Manufacturer == "cpu" { + cpuOnly = append(cpuOnly, inst) + } + } + 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 +// +//nolint:gocyclo func SortInstances(instances []GPUInstanceInfo, sortBy string, descending bool) { sort.Slice(instances, func(i, j int) bool { var less bool @@ -755,15 +945,18 @@ 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 { 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: @@ -845,6 +1038,7 @@ type formattedInstanceFields struct { VRAM string TotalVRAM string Capability string + RAM string Disk string DiskPrice string Boot string @@ -881,10 +1075,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 +1163,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(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..d9972d68 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 0, false, false, false, true) assert.Len(t, noFilter, 3, "Without filter, all 3 instances should be included") } diff --git a/pkg/cmd/ls/ls.go b/pkg/cmd/ls/ls.go index da95aa89..929ebfba 100644 --- a/pkg/cmd/ls/ls.go +++ b/pkg/cmd/ls/ls.go @@ -5,10 +5,12 @@ import ( "encoding/json" "fmt" "os" + "sync" "github.com/brevdev/brev-cli/pkg/analytics" "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" "github.com/brevdev/brev-cli/pkg/cmd/completions" + "github.com/brevdev/brev-cli/pkg/cmd/gpusearch" "github.com/brevdev/brev-cli/pkg/cmd/hello" cmdutil "github.com/brevdev/brev-cli/pkg/cmd/util" "github.com/brevdev/brev-cli/pkg/cmdcontext" @@ -32,6 +34,7 @@ type LsStore interface { GetUsers(queryParams map[string]string) ([]entity.User, error) GetWorkspace(workspaceID string) (*entity.Workspace, error) GetOrganizations(options *store.GetOrganizationsOptions) ([]entity.Organization, error) + GetInstanceTypes(includeCPU bool) (*gpusearch.InstanceTypesResponse, error) hello.HelloStore } @@ -329,9 +332,9 @@ func (ls Ls) RunUser(_ bool) error { return nil } -func (ls Ls) ShowAllWorkspaces(org *entity.Organization, otherOrgs []entity.Organization, user *entity.User, allWorkspaces []entity.Workspace) { +func (ls Ls) ShowAllWorkspaces(org *entity.Organization, otherOrgs []entity.Organization, user *entity.User, allWorkspaces []entity.Workspace, gpuLookup map[string]string) { userWorkspaces := store.FilterForUserWorkspaces(allWorkspaces, user.ID) - ls.displayWorkspacesAndHelp(org, otherOrgs, userWorkspaces, allWorkspaces) + ls.displayWorkspacesAndHelp(org, otherOrgs, userWorkspaces, allWorkspaces, gpuLookup) projects := virtualproject.NewVirtualProjects(allWorkspaces) @@ -346,13 +349,13 @@ func (ls Ls) ShowAllWorkspaces(org *entity.Organization, otherOrgs []entity.Orga displayProjects(ls.terminal, org.Name, unjoinedProjects) } -func (ls Ls) ShowUserWorkspaces(org *entity.Organization, otherOrgs []entity.Organization, user *entity.User, allWorkspaces []entity.Workspace) { +func (ls Ls) ShowUserWorkspaces(org *entity.Organization, otherOrgs []entity.Organization, user *entity.User, allWorkspaces []entity.Workspace, gpuLookup map[string]string) { userWorkspaces := store.FilterForUserWorkspaces(allWorkspaces, user.ID) - ls.displayWorkspacesAndHelp(org, otherOrgs, userWorkspaces, allWorkspaces) + ls.displayWorkspacesAndHelp(org, otherOrgs, userWorkspaces, allWorkspaces, gpuLookup) } -func (ls Ls) displayWorkspacesAndHelp(org *entity.Organization, otherOrgs []entity.Organization, userWorkspaces []entity.Workspace, allWorkspaces []entity.Workspace) { +func (ls Ls) displayWorkspacesAndHelp(org *entity.Organization, otherOrgs []entity.Organization, userWorkspaces []entity.Workspace, allWorkspaces []entity.Workspace, gpuLookup map[string]string) { if len(userWorkspaces) == 0 { ls.terminal.Vprint(ls.terminal.Yellow("No instances in org %s\n", org.Name)) if len(allWorkspaces) > 0 { @@ -368,7 +371,7 @@ func (ls Ls) displayWorkspacesAndHelp(org *entity.Organization, otherOrgs []enti } } else { ls.terminal.Vprintf("You have %d instances in Org %s\n", len(userWorkspaces), ls.terminal.Yellow(org.Name)) - displayWorkspacesTable(ls.terminal, userWorkspaces) + displayWorkspacesTable(ls.terminal, userWorkspaces, gpuLookup) fmt.Print("\n") @@ -393,10 +396,44 @@ func displayLsResetBreadCrumb(t *terminal.Terminal, workspaces []entity.Workspac } } +// buildGPULookup builds a map of instance type name to GPU name. +// Returns nil if the fetch fails (graceful degradation). +func buildGPULookup(s LsStore) map[string]string { + resp, err := s.GetInstanceTypes(true) + if err != nil || resp == nil { + return nil + } + lookup := make(map[string]string, len(resp.Items)) + for _, item := range resp.Items { + if len(item.SupportedGPUs) > 0 { + lookup[item.Type] = item.SupportedGPUs[0].Name + } else { + lookup[item.Type] = "-" + } + } + return lookup +} + func (ls Ls) RunWorkspaces(org *entity.Organization, user *entity.User, showAll bool) error { - allWorkspaces, err := ls.lsStore.GetWorkspaces(org.ID, nil) - if err != nil { - return breverrors.WrapAndTrace(err) + // Fetch workspaces and instance types concurrently + var allWorkspaces []entity.Workspace + var wsErr error + var gpuLookup map[string]string + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + allWorkspaces, wsErr = ls.lsStore.GetWorkspaces(org.ID, nil) + }() + go func() { + defer wg.Done() + gpuLookup = buildGPULookup(ls.lsStore) + }() + wg.Wait() + + if wsErr != nil { + return breverrors.WrapAndTrace(wsErr) } // Determine which workspaces to show @@ -409,7 +446,7 @@ func (ls Ls) RunWorkspaces(org *entity.Organization, user *entity.User, showAll // Handle JSON output if ls.jsonOutput { - return ls.outputWorkspacesJSON(workspacesToShow) + return ls.outputWorkspacesJSON(workspacesToShow, gpuLookup) } // Table output with colors and help text @@ -418,9 +455,9 @@ func (ls Ls) RunWorkspaces(org *entity.Organization, user *entity.User, showAll return breverrors.WrapAndTrace(err) } if showAll { - ls.ShowAllWorkspaces(org, orgs, user, allWorkspaces) + ls.ShowAllWorkspaces(org, orgs, user, allWorkspaces, gpuLookup) } else { - ls.ShowUserWorkspaces(org, orgs, user, allWorkspaces) + ls.ShowUserWorkspaces(org, orgs, user, allWorkspaces, gpuLookup) } return nil } @@ -435,12 +472,31 @@ type WorkspaceInfo struct { HealthStatus string `json:"health_status"` InstanceType string `json:"instance_type"` InstanceKind string `json:"instance_kind"` + GPU string `json:"gpu"` +} + +// getGPUForInstance returns the GPU name for an instance type using the lookup map. +// Returns the GPU name (e.g. "A100"), "-" for CPU-only, or "-" if unknown. +func getGPUForInstance(w entity.Workspace, gpuLookup map[string]string) string { + if w.InstanceType != "" && gpuLookup != nil { + if gpu, ok := gpuLookup[w.InstanceType]; ok { + return gpu + } + } + if w.InstanceType == "" && w.WorkspaceClassID != "" { + return "-" + } + return "-" } // getInstanceTypeAndKind returns the instance type and kind (gpu/cpu) -func getInstanceTypeAndKind(w entity.Workspace) (string, string) { +func getInstanceTypeAndKind(w entity.Workspace, gpuLookup map[string]string) (string, string) { if w.InstanceType != "" { - return w.InstanceType, "gpu" + gpu := getGPUForInstance(w, gpuLookup) + if gpu != "-" { + return w.InstanceType, "gpu" + } + return w.InstanceType, "cpu" } if w.WorkspaceClassID != "" { return w.WorkspaceClassID, "cpu" @@ -448,10 +504,10 @@ func getInstanceTypeAndKind(w entity.Workspace) (string, string) { return "", "" } -func (ls Ls) outputWorkspacesJSON(workspaces []entity.Workspace) error { +func (ls Ls) outputWorkspacesJSON(workspaces []entity.Workspace, gpuLookup map[string]string) error { var infos []WorkspaceInfo for _, w := range workspaces { - instanceType, instanceKind := getInstanceTypeAndKind(w) + instanceType, instanceKind := getInstanceTypeAndKind(w, gpuLookup) infos = append(infos, WorkspaceInfo{ Name: w.Name, ID: w.ID, @@ -461,6 +517,7 @@ func (ls Ls) outputWorkspacesJSON(workspaces []entity.Workspace) error { HealthStatus: w.HealthStatus, InstanceType: instanceType, InstanceKind: instanceKind, + GPU: getGPUForInstance(w, gpuLookup), }) } output, err := json.MarshalIndent(infos, "", " ") @@ -514,16 +571,17 @@ func getBrevTableOptions() table.Options { return options } -func displayWorkspacesTable(t *terminal.Terminal, workspaces []entity.Workspace) { +func displayWorkspacesTable(t *terminal.Terminal, workspaces []entity.Workspace, gpuLookup map[string]string) { ta := table.NewWriter() ta.SetOutputMirror(os.Stdout) ta.Style().Options = getBrevTableOptions() - header := table.Row{"Name", "Status", "Build", "Shell", "ID", "Machine"} + header := table.Row{"Name", "Status", "Build", "Shell", "ID", "Machine", "GPU"} ta.AppendHeader(header) for _, w := range workspaces { status := getWorkspaceDisplayStatus(w) instanceString := cmdutil.GetInstanceString(w) - workspaceRow := []table.Row{{w.Name, getStatusColoredText(t, status), getStatusColoredText(t, string(w.VerbBuildStatus)), getStatusColoredText(t, getShellDisplayStatus(w)), w.ID, instanceString}} + gpu := getGPUForInstance(w, gpuLookup) + workspaceRow := []table.Row{{w.Name, getStatusColoredText(t, status), getStatusColoredText(t, string(w.VerbBuildStatus)), getStatusColoredText(t, getShellDisplayStatus(w)), w.ID, instanceString, gpu}} ta.AppendRows(workspaceRow) } ta.Render() @@ -550,16 +608,17 @@ func getWorkspaceDisplayStatus(w entity.Workspace) string { // displayWorkspacesTablePlain outputs a clean table without colors for piping // Enables: brev ls | grep RUNNING | awk '{print $1}' | brev stop -func displayWorkspacesTablePlain(workspaces []entity.Workspace) { //nolint:unused // see TODO above +func displayWorkspacesTablePlain(workspaces []entity.Workspace, gpuLookup map[string]string) { //nolint:unused // see TODO above ta := table.NewWriter() ta.SetOutputMirror(os.Stdout) ta.Style().Options = getBrevTableOptions() - header := table.Row{"NAME", "STATUS", "BUILD", "SHELL", "ID", "MACHINE"} + header := table.Row{"NAME", "STATUS", "BUILD", "SHELL", "ID", "MACHINE", "GPU"} ta.AppendHeader(header) for _, w := range workspaces { status := getWorkspaceDisplayStatus(w) instanceString := cmdutil.GetInstanceString(w) - workspaceRow := []table.Row{{w.Name, status, string(w.VerbBuildStatus), getShellDisplayStatus(w), w.ID, instanceString}} + gpu := getGPUForInstance(w, gpuLookup) + workspaceRow := []table.Row{{w.Name, status, string(w.VerbBuildStatus), getShellDisplayStatus(w), w.ID, instanceString, gpu}} ta.AppendRows(workspaceRow) } ta.Render() diff --git a/pkg/cmd/util/util.go b/pkg/cmd/util/util.go index 88855168..8d841349 100644 --- a/pkg/cmd/util/util.go +++ b/pkg/cmd/util/util.go @@ -86,11 +86,11 @@ func GetClassIDString(classID string) string { } func GetInstanceString(w entity.Workspace) string { - var instanceString string + if w.InstanceType != "" { + return w.InstanceType + } if w.WorkspaceClassID != "" { - instanceString = GetClassIDString(w.WorkspaceClassID) - } else { - instanceString = w.InstanceType + " (gpu)" + return GetClassIDString(w.WorkspaceClassID) } - return instanceString + return "" } 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) }