Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions internal/api/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
38 changes: 38 additions & 0 deletions internal/api/storage_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 7 additions & 0 deletions internal/commands/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
55 changes: 54 additions & 1 deletion internal/commands/instances_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package commands

import "testing"
import (
"encoding/json"
"strings"
"testing"
)

func TestParseGPUFlag(t *testing.T) {
tests := []struct {
Expand Down Expand Up @@ -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)
}
})
}
}
36 changes: 26 additions & 10 deletions internal/commands/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
Expand All @@ -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")
Expand All @@ -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,
Expand All @@ -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 <id>",
Aliases: []string{"rm"},
Expand Down Expand Up @@ -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)
Expand Down
53 changes: 53 additions & 0 deletions internal/commands/storage_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading