diff --git a/internal/api/instances.go b/internal/api/instances.go index 2939f36..f8a3ece 100644 --- a/internal/api/instances.go +++ b/internal/api/instances.go @@ -9,17 +9,22 @@ import ( // Instance represents an instance from the v1 API. type Instance struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - GPUType string `json:"gpu_type"` - GPUCount int `json:"gpu_count"` - CPUCores int `json:"cpu_cores"` - Memory int `json:"memory"` - Storage int `json:"storage"` - Region string `json:"region"` - IP string `json:"ip"` - OSImage string `json:"os_image"` + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + GPUType string `json:"gpu_type"` + GPUCount int `json:"gpu_count"` + CPUCores int `json:"cpu_cores"` + Memory int `json:"memory"` + Storage int `json:"storage"` + Region string `json:"region"` + IP string `json:"ip"` + OSImage string `json:"os_image"` + // DeploymentType is the kind of machine: vm, baremetal or container. + DeploymentType string `json:"deployment_type"` + // HostTier is who runs the machine: "datacenter", or "community" for an + // independent host who can access it. Absent reads as datacenter. + HostTier string `json:"host_tier"` CostPerHour float64 `json:"cost_per_hour"` DeployedAt string `json:"deployed_at"` CreatedAt string `json:"created_at"` @@ -43,6 +48,7 @@ type InstanceType struct { StorageGB int `json:"storage_gb"` Region string `json:"region"` DeploymentType string `json:"deployment_type"` + HostTier string `json:"host_tier"` HourlyRate float64 `json:"hourly_rate"` } @@ -69,6 +75,10 @@ type CreateInstanceRequest struct { // Optional auto stop guardrails, in whole minutes. Omitted when nil. MaxRuntimeMinutes *int `json:"max_runtime_minutes,omitempty"` IdleShutdownMinutes *int `json:"idle_shutdown_minutes,omitempty"` + + // IncludeCommunity lets a community host run it. Off (omitted): datacenter + // machines only. + IncludeCommunity bool `json:"include_community,omitempty"` } // EnvVar is a key-value pair for instance environment variables. @@ -147,8 +157,9 @@ func (c *Client) ListInstances(search string) ([]Instance, error) { return result.Data, nil } -// ListInstanceTypes returns available GPU instance types with optional filters. -func (c *Client) ListInstanceTypes(gpuType, region string, gpuCount int) ([]InstanceType, error) { +// ListInstanceTypes returns available GPU instance types with optional filters: +// datacenter machines, plus community hosts when includeCommunity is set. +func (c *Client) ListInstanceTypes(gpuType, region string, gpuCount int, includeCommunity bool) ([]InstanceType, error) { var params []string if gpuType != "" { params = append(params, "gpu_type="+gpuType) @@ -159,6 +170,9 @@ func (c *Client) ListInstanceTypes(gpuType, region string, gpuCount int) ([]Inst if gpuCount > 0 { params = append(params, fmt.Sprintf("gpu_count=%d", gpuCount)) } + if includeCommunity { + params = append(params, "include_community=true") + } path := "/api/v1/instances/types" if len(params) > 0 { diff --git a/internal/api/instances_test.go b/internal/api/instances_test.go index 0fbc50a..e10a70e 100644 --- a/internal/api/instances_test.go +++ b/internal/api/instances_test.go @@ -27,6 +27,10 @@ func mockInstancesServer(t *testing.T) *httptest.Server { {ID: "type-a100", GPUType: "A100", GPUCount: 1, CPUCores: 30, MemoryGB: 200, StorageGB: 512, Region: "mumbai-india-1", HourlyRate: 1.42}, {ID: "type-h100", GPUType: "H100", GPUCount: 1, CPUCores: 32, MemoryGB: 256, StorageGB: 1024, Region: "us-east-1", HourlyRate: 3.50}, } + // Community hosts are listed only to a caller who opts in, as the API does. + if r.URL.Query().Get("include_community") == "true" { + types = append(types, InstanceType{ID: "type-4090", GPUType: "RTX4090", GPUCount: 1, Region: "pl-waw", DeploymentType: "container", HostTier: "community", HourlyRate: 0.89}) + } if gpu != "" { filtered := []InstanceType{} for _, t := range types { @@ -111,7 +115,7 @@ func TestListInstanceTypes(t *testing.T) { client := NewClient("rc_live_test", server.URL) // Unfiltered - types, err := client.ListInstanceTypes("", "", 0) + types, err := client.ListInstanceTypes("", "", 0, false) if err != nil { t.Fatalf("ListInstanceTypes() error: %v", err) } @@ -119,8 +123,17 @@ func TestListInstanceTypes(t *testing.T) { t.Fatalf("got %d types, want 2", len(types)) } + // Community hosts only when asked for + types, err = client.ListInstanceTypes("", "", 0, true) + if err != nil { + t.Fatalf("ListInstanceTypes(include community) error: %v", err) + } + if len(types) != 3 || types[2].HostTier != "community" { + t.Fatalf("with community hosts got %+v, want the 2 datacenter types and the community one", types) + } + // Filtered by GPU - types, err = client.ListInstanceTypes("A100", "", 0) + types, err = client.ListInstanceTypes("A100", "", 0, false) if err != nil { t.Fatalf("ListInstanceTypes(A100) error: %v", err) } diff --git a/internal/commands/instances.go b/internal/commands/instances.go index a4dbd78..508b7c4 100644 --- a/internal/commands/instances.go +++ b/internal/commands/instances.go @@ -76,6 +76,34 @@ func formatOptionalMinutes(minutes *int) string { return formatMinutes(*minutes) } +// kindText is how a machine's kind (the API's deployment_type) reads: +// "VM", "Bare metal" or "Container". Anything else is shown as sent, and +// nothing as "-". +func kindText(deploymentType string) string { + switch deploymentType { + case "vm": + return "VM" + case "baremetal": + return "Bare metal" + case "container": + return "Container" + case "": + return "-" + default: + return deploymentType + } +} + +// hostText is who runs a machine (the API's host_tier): "Community" for an +// independent host, otherwise "Datacenter" (absent included: every machine +// before community hosts ran in one). +func hostText(hostTier string) string { + if hostTier == "community" { + return "Community" + } + return "Datacenter" +} + // stopReasonText maps the API stop_reason to plain words. func stopReasonText(reason string) string { switch reason { @@ -167,7 +195,7 @@ var instancesListCmd = &cobra.Command{ } w := newTable() - fmt.Fprintf(w, "NAME\tSTATUS\tGPU\tIP\t$/HR\n") + fmt.Fprintf(w, "NAME\tSTATUS\tGPU\tKIND\tHOST\tIP\t$/HR\n") for _, inst := range instances { gpu := inst.GPUType if inst.GPUCount > 1 { @@ -177,10 +205,10 @@ var instancesListCmd = &cobra.Command{ if ip == "" { ip = "-" } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t$%.2f\n", + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t$%.2f\n", truncate(inst.Name, 24), statusColor(inst.Status), - gpu, ip, inst.CostPerHour, + gpu, kindText(inst.DeploymentType), hostText(inst.HostTier), ip, inst.CostPerHour, ) } return w.Flush() @@ -207,7 +235,8 @@ var instancesTypesCmd = &cobra.Command{ return err } - types, err := client.ListInstanceTypes(gpuType, region, gpuCount) + includeCommunity, _ := cmd.Flags().GetBool("include-community") + types, err := client.ListInstanceTypes(gpuType, region, gpuCount, includeCommunity) if err != nil { return fmt.Errorf("fetching instance types: %w", err) } @@ -222,11 +251,11 @@ var instancesTypesCmd = &cobra.Command{ } w := newTable() - fmt.Fprintf(w, "ID\tGPU\tCOUNT\tCPU\tRAM\tSTORAGE\tREGION\t$/HR\n") + fmt.Fprintf(w, "ID\tGPU\tCOUNT\tKIND\tHOST\tCPU\tRAM\tSTORAGE\tREGION\t$/HR\n") for _, t := range types { - fmt.Fprintf(w, "%s\t%s\t%d\t%d cores\t%d GB\t%d GB\t%s\t$%.2f\n", + fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%d cores\t%d GB\t%d GB\t%s\t$%.2f\n", t.ID, - t.GPUType, t.GPUCount, + t.GPUType, t.GPUCount, kindText(t.DeploymentType), hostText(t.HostTier), t.CPUCores, t.MemoryGB, t.StorageGB, t.Region, t.HourlyRate, ) @@ -239,14 +268,19 @@ var instancesTypesCmd = &cobra.Command{ // addInstanceOnlyFlags registers the create flags that only an instance takes, // shared by instances create and schedule create. +// communityFlagHelp says what --include-community opts in to. +const communityFlagHelp = "Also use machines run by community hosts: independent operators who run the hardware and can access the machine, never on reserved terms. Off by default (datacenter machines only)" + func addInstanceOnlyFlags(cmd *cobra.Command) { - cmd.Flags().String("type-id", "", "Instance type ID from 'runcrate instances types'") + cmd.Flags().String("type-id", "", "Instance type ID from 'runcrate instances types' (a community host's needs --include-community)") + cmd.Flags().Bool("include-community", false, communityFlagHelp) cmd.Flags().String("idle-stop", "", "Stop the instance after this long with GPU use under 5% and nobody connected (SSH, web terminal or editor), 5m to 24h (e.g. 30m), GPU instances only. Recommended for unattended work") } // buildInstanceRequest turns the create flags (name, gpu, gpu-count, region, -// template, type-id, volume, max-runtime, idle-stop) into the create body. +// template, type-id, volume, max-runtime, idle-stop, include-community) into +// the create body. func buildInstanceRequest(cmd *cobra.Command) (api.CreateInstanceRequest, error) { var req api.CreateInstanceRequest name, _ := cmd.Flags().GetString("name") @@ -258,6 +292,7 @@ func buildInstanceRequest(cmd *cobra.Command) (api.CreateInstanceRequest, error) volumeID, _ := cmd.Flags().GetString("volume") maxRuntimeRaw, _ := cmd.Flags().GetString("max-runtime") idleStopRaw, _ := cmd.Flags().GetString("idle-stop") + includeCommunity, _ := cmd.Flags().GetBool("include-community") maxRuntime, err := parseMinutesFlag("max-runtime", maxRuntimeRaw, maxRuntimeMaxMinutes) if err != nil { @@ -290,6 +325,7 @@ func buildInstanceRequest(cmd *cobra.Command) (api.CreateInstanceRequest, error) MaxRuntimeMinutes: maxRuntime, IdleShutdownMinutes: idleStop, StorageID: volumeID, + IncludeCommunity: includeCommunity, }, nil } @@ -391,6 +427,8 @@ var instancesInfoCmd = &cobra.Command{ fmt.Printf(" ID: %s\n", instance.ID) fmt.Printf(" Status: %s\n", statusColor(instance.Status)) fmt.Printf(" GPU: %dx %s\n", instance.GPUCount, instance.GPUType) + fmt.Printf(" Kind: %s\n", kindText(instance.DeploymentType)) + fmt.Printf(" Host: %s\n", hostText(instance.HostTier)) fmt.Printf(" CPU: %d cores\n", instance.CPUCores) fmt.Printf(" Memory: %d GB\n", instance.Memory) fmt.Printf(" Storage: %d GB\n", instance.Storage) @@ -600,6 +638,7 @@ func init() { instancesTypesCmd.Flags().String("gpu", "", "Filter by GPU type (e.g., A100 or A100:4)") instancesTypesCmd.Flags().String("region", "", "Filter by region (e.g., us-east)") instancesTypesCmd.Flags().Int("gpu-count", 0, "Filter by GPU count") + instancesTypesCmd.Flags().Bool("include-community", false, communityFlagHelp) // create instancesCreateCmd.Flags().String("name", "", "Instance name (required)") diff --git a/internal/commands/instances_test.go b/internal/commands/instances_test.go index f7001cc..73f0108 100644 --- a/internal/commands/instances_test.go +++ b/internal/commands/instances_test.go @@ -181,3 +181,11 @@ func TestParseAutoStopFlag(t *testing.T) { t.Error("empty value accepted, want an error") } } + +func TestKindText(t *testing.T) { + for in, want := range map[string]string{"vm": "VM", "baremetal": "Bare metal", "container": "Container", "": "-", "gpu-pod": "gpu-pod"} { + if got := kindText(in); got != want { + t.Errorf("kindText(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/commands/schedule.go b/internal/commands/schedule.go index e77a3d8..cf653b1 100644 --- a/internal/commands/schedule.go +++ b/internal/commands/schedule.go @@ -144,7 +144,7 @@ func resolveTimezone(name string) (*time.Location, error) { // Flags that only one kind takes. var ( - instanceOnlyScheduleFlags = []string{"type-id", "idle-stop"} + instanceOnlyScheduleFlags = []string{"type-id", "idle-stop", "include-community"} jobOnlyScheduleFlags = []string{"image", "env"} )