Skip to content
Open
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
40 changes: 27 additions & 13 deletions internal/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
}

Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
17 changes: 15 additions & 2 deletions internal/api/instances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -111,16 +115,25 @@ 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)
}
if len(types) != 2 {
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)
}
Expand Down
57 changes: 48 additions & 9 deletions internal/commands/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -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)
}
Expand All @@ -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,
)
Expand All @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -290,6 +325,7 @@ func buildInstanceRequest(cmd *cobra.Command) (api.CreateInstanceRequest, error)
MaxRuntimeMinutes: maxRuntime,
IdleShutdownMinutes: idleStop,
StorageID: volumeID,
IncludeCommunity: includeCommunity,
}, nil
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)")
Expand Down
8 changes: 8 additions & 0 deletions internal/commands/instances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
2 changes: 1 addition & 1 deletion internal/commands/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)

Expand Down
Loading