diff --git a/internal/api/storage.go b/internal/api/storage.go index f4fdac9..41d8815 100644 --- a/internal/api/storage.go +++ b/internal/api/storage.go @@ -6,15 +6,16 @@ import ( "strings" ) +// StorageVolume is a volume as GET /api/v1/storage answers it (camelCase, the API's StorageVolume): +// Region is the id of the region it was made in, one of /api/v1/storage/regions (a volume made long +// ago may hold a region name instead). type StorageVolume struct { - ID string `json:"id"` - Name string `json:"name"` - SizeGB int `json:"size_gb"` - Region string `json:"region"` - Status string `json:"status"` - MountPath string `json:"mount_path"` - CostPerHr float64 `json:"cost_per_hour"` - CreatedAt string `json:"created_at"` + ID string `json:"id"` + Name string `json:"name"` + SizeGB int `json:"sizeGb"` + Region string `json:"region"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` } type StorageRegion struct { diff --git a/internal/api/storage_test.go b/internal/api/storage_test.go new file mode 100644 index 0000000..9a8d0d5 --- /dev/null +++ b/internal/api/storage_test.go @@ -0,0 +1,38 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// The body GET /api/v1/storage answers: camelCase keys, the region as its id (the API's StorageVolume). +const storageListBody = `{"data":[{"id":"vol-1","name":"datasets","sizeGb":1000,"status":"active","region":"582",` + + `"providerType":"wasabi","deploymentId":null,"projectId":"p1","environmentId":null,` + + `"createdAt":"2026-11-01T10:00:00.000Z","updatedAt":"2026-11-02T10:00:00.000Z"}]}` + +func TestListStorageReadsTheServersShape(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/api/v1/storage" { + http.NotFound(w, r) + return + } + w.Write([]byte(storageListBody)) + })) + defer server.Close() + + volumes, err := NewClient("rc_live_test", server.URL).ListStorage() + if err != nil { + t.Fatalf("ListStorage: %v", err) + } + if len(volumes) != 1 { + t.Fatalf("got %d volumes, want 1", len(volumes)) + } + v := volumes[0] + if v.SizeGB != 1000 { + t.Errorf("SizeGB = %d, want 1000 (the API sends sizeGb)", v.SizeGB) + } + if v.Region != "582" || v.Status != "active" || v.CreatedAt != "2026-11-01T10:00:00.000Z" { + t.Errorf("volume = %+v", v) + } +} diff --git a/internal/commands/instances.go b/internal/commands/instances.go index a4dbd78..3b1b955 100644 --- a/internal/commands/instances.go +++ b/internal/commands/instances.go @@ -273,6 +273,13 @@ func buildInstanceRequest(cmd *cobra.Command) (api.CreateInstanceRequest, error) return req, err } + // A type fixes its own GPU count (a CPU type has none) and the server refuses a gpu_count beside + // instance_type_id that is not the type's. --gpu-count defaults to 1, so with --type-id send a + // count only when the user gave one (the flag or the colon form). + if typeID != "" && !cmd.Flags().Changed("gpu-count") && !strings.Contains(gpuRaw, ":") { + gpuCount = 0 + } + if name == "" { return req, fmt.Errorf("--name is required") } diff --git a/internal/commands/instances_test.go b/internal/commands/instances_test.go index f7001cc..490c635 100644 --- a/internal/commands/instances_test.go +++ b/internal/commands/instances_test.go @@ -1,6 +1,10 @@ package commands -import "testing" +import ( + "encoding/json" + "strings" + "testing" +) func TestParseGPUFlag(t *testing.T) { tests := []struct { @@ -181,3 +185,52 @@ func TestParseAutoStopFlag(t *testing.T) { t.Error("empty value accepted, want an error") } } + +// resetCreateFlags puts the flags of `instances create` back to their defaults after a test sets some. +func resetCreateFlags(t *testing.T) { + t.Helper() + t.Cleanup(func() { + for _, name := range []string{"name", "gpu", "gpu-count", "type-id"} { + f := instancesCreateCmd.Flags().Lookup(name) + f.Value.Set(f.DefValue) + f.Changed = false + } + }) +} + +// --gpu-count defaults to 1, and the server refuses a gpu_count beside a type id that is not the type's +// (a CPU type has 0 GPUs, a big type 8): with --type-id a count is sent only when the user gave one. +func TestBuildInstanceRequestGPUCountBesideTypeID(t *testing.T) { + tests := []struct { + name string + flags map[string]string + want int + }{ + {"a type id alone sends no count", map[string]string{"type-id": "cpu-box"}, 0}, + {"a type id with --gpu-count sends it", map[string]string{"type-id": "h100x8", "gpu-count": "8"}, 8}, + {"a type id with the colon form sends its count", map[string]string{"type-id": "h100x2", "gpu": "H100:2"}, 2}, + {"auto-deploy without a count is one GPU", map[string]string{"gpu": "H100"}, 1}, + {"auto-deploy with the colon form", map[string]string{"gpu": "A100:4"}, 4}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetCreateFlags(t) + instancesCreateCmd.Flags().Set("name", "box") + for name, value := range tt.flags { + if err := instancesCreateCmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + req, err := buildInstanceRequest(instancesCreateCmd) + if err != nil { + t.Fatalf("buildInstanceRequest: %v", err) + } + if req.GPUCount != tt.want { + t.Errorf("GPUCount = %d, want %d", req.GPUCount, tt.want) + } + if body, _ := json.Marshal(req); tt.want == 0 && strings.Contains(string(body), "gpu_count") { + t.Errorf("body %s carries gpu_count", body) + } + }) + } +} diff --git a/internal/commands/storage.go b/internal/commands/storage.go index e366a43..807153b 100644 --- a/internal/commands/storage.go +++ b/internal/commands/storage.go @@ -38,11 +38,13 @@ var storageListCmd = &cobra.Command{ return nil } + // A volume's region is an id; show the name `volumes regions` gives it. A failed lookup shows the id. + regions, _ := client.ListStorageRegions() + w := newTable() - fmt.Fprintf(w, "NAME\tSIZE\tREGION\tSTATUS\t$/HR\n") + fmt.Fprintf(w, "NAME\tSIZE\tREGION\tSTATUS\n") for _, v := range volumes { - fmt.Fprintf(w, "%s\t%d GB\t%s\t%s\t$%.4f\n", - v.Name, v.SizeGB, v.Region, v.Status, v.CostPerHr) + fmt.Fprintf(w, "%s\t%d GB\t%s\t%s\n", v.Name, v.SizeGB, regionLabel(regions, v.Region), v.Status) } return w.Flush() }, @@ -52,11 +54,6 @@ var storageCreateCmd = &cobra.Command{ Use: "create", Short: "Create a storage volume", RunE: func(cmd *cobra.Command, args []string) error { - client, err := loadClient() - if err != nil { - return err - } - name, _ := cmd.Flags().GetString("name") size, _ := cmd.Flags().GetInt("size") region, _ := cmd.Flags().GetString("region") @@ -67,6 +64,14 @@ var storageCreateCmd = &cobra.Command{ if size <= 0 { return fmt.Errorf("--size is required (in GB)") } + if region == "" { + return fmt.Errorf("--region is required: an id or name from 'runcrate volumes regions'") + } + + client, err := loadClient() + if err != nil { + return err + } vol, err := client.CreateStorage(api.CreateStorageRequest{ Name: name, @@ -78,12 +83,23 @@ var storageCreateCmd = &cobra.Command{ } dim := lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - fmt.Printf("\n Created %s %s\n\n", vol.Name, dim.Render(fmt.Sprintf("(%d GB, %s)", vol.SizeGB, vol.Region))) + fmt.Printf("\n Created %s %s\n\n", vol.Name, dim.Render(fmt.Sprintf("(%d GB, %s)", vol.SizeGB, region))) return nil }, } +// regionLabel names a volume's region: its id is the id of a row of `volumes regions`, whose name is +// what to show. Anything else (a volume made long ago holds a region name) is shown as it is. +func regionLabel(regions []api.StorageRegion, region string) string { + for _, r := range regions { + if r.ID == region { + return r.Name + } + } + return region +} + var storageDeleteCmd = &cobra.Command{ Use: "delete ", Aliases: []string{"rm"}, @@ -160,7 +176,7 @@ func init() { storageCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output as JSON") storageCreateCmd.Flags().String("name", "", "Volume name (required)") storageCreateCmd.Flags().Int("size", 0, "Size in GB (required)") - storageCreateCmd.Flags().String("region", "", "Region") + storageCreateCmd.Flags().String("region", "", "Region id or name from 'runcrate volumes regions' (required)") storageResizeCmd.Flags().Int("size", 0, "New size in GB (required)") storageCmd.AddCommand(storageListCmd) diff --git a/internal/commands/storage_test.go b/internal/commands/storage_test.go new file mode 100644 index 0000000..e35ccbe --- /dev/null +++ b/internal/commands/storage_test.go @@ -0,0 +1,53 @@ +package commands + +import ( + "strings" + "testing" + + "github.com/runcrate/cli/internal/api" +) + +func TestRegionLabel(t *testing.T) { + regions := []api.StorageRegion{ + {ID: "137", Name: "us-east-1", Provider: "wasabi"}, + {ID: "582", Name: "eu-west-1", Provider: "wasabi"}, + } + tests := []struct { + name string + regions []api.StorageRegion + region string + want string + }{ + {"an id shows its name", regions, "582", "eu-west-1"}, + {"a region name from an older volume is shown as it is", regions, "us-west-1", "us-west-1"}, + {"an id nobody lists is shown as it is", regions, "999", "999"}, + {"no regions (the lookup failed) shows the id", nil, "582", "582"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := regionLabel(tt.regions, tt.region); got != tt.want { + t.Errorf("regionLabel(%q) = %q, want %q", tt.region, got, tt.want) + } + }) + } +} + +// The API refuses a volume without a region ("Missing required fields: name, size_gb, region"), so the +// command says so before it signs in or sends anything. +func TestVolumesCreateNeedsARegion(t *testing.T) { + flags := storageCreateCmd.Flags() + t.Cleanup(func() { + for _, name := range []string{"name", "size", "region"} { + f := flags.Lookup(name) + f.Value.Set(f.DefValue) + f.Changed = false + } + }) + flags.Set("name", "datasets") + flags.Set("size", "100") + + err := storageCreateCmd.RunE(storageCreateCmd, nil) + if err == nil || !strings.Contains(err.Error(), "--region is required") { + t.Fatalf("err = %v, want one naming --region", err) + } +}