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
12 changes: 0 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,6 @@ runcrate instances info train # Max runtime, Idle stop, Auto stop at, Stop rea
runcrate instances auto-stop train --max-runtime 8h --idle-stop off
```

`--volume <volume-id>` attaches a volume at `/workspace`. By default it is copied to the
machine's disk at boot and your changes are copied back (`--volume-mode copy`). With
`--volume-mode mount_ro` the volume is mounted read-only at `/workspace` and ready in
seconds, nothing is copied, and what you write goes to `/root/workspace`, which is not
saved. A machine or volume that cannot mount is refused with `VOLUME_MODE_UNSUPPORTED`;
it is never quietly turned into a copy. `--volume-mode` needs `--volume`, and
`schedule create` takes it for an instance schedule too (jobs always copy).

```bash
runcrate instances create --name infer --gpu H100 --volume <volume-id> --volume-mode mount_ro
```

Point the CLI at a different API origin with `runcrate config set url <url>` or the
`RUNCRATE_API_URL` env var (defaults to `https://runcrate.ai`).

Expand Down
23 changes: 10 additions & 13 deletions internal/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,16 @@ type InstanceStatus struct {

// CreateInstanceRequest is the payload for creating an instance.
type CreateInstanceRequest struct {
Name string `json:"name"`
GPUType string `json:"gpu_type,omitempty"`
GPUCount int `json:"gpu_count,omitempty"`
Region string `json:"region,omitempty"`
SSHKeyID string `json:"ssh_key_id,omitempty"`
Template string `json:"template,omitempty"`
InstanceTypeID string `json:"instance_type_id,omitempty"`
StorageID string `json:"storage_id,omitempty"`
// VolumeMode is how the volume reaches the machine, with StorageID: "copy" or "mount_ro". Omitted
// when empty, which the API reads as "copy".
VolumeMode string `json:"volume_mode,omitempty"`
EnvVars []EnvVar `json:"env_vars,omitempty"`
StartupCmds []string `json:"startup_commands,omitempty"`
Name string `json:"name"`
GPUType string `json:"gpu_type,omitempty"`
GPUCount int `json:"gpu_count,omitempty"`
Region string `json:"region,omitempty"`
SSHKeyID string `json:"ssh_key_id,omitempty"`
Template string `json:"template,omitempty"`
InstanceTypeID string `json:"instance_type_id,omitempty"`
StorageID string `json:"storage_id,omitempty"`
EnvVars []EnvVar `json:"env_vars,omitempty"`
StartupCmds []string `json:"startup_commands,omitempty"`

// Optional auto stop guardrails, in whole minutes. Omitted when nil.
MaxRuntimeMinutes *int `json:"max_runtime_minutes,omitempty"`
Expand Down
29 changes: 0 additions & 29 deletions internal/api/instances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,35 +229,6 @@ func TestCreateInstanceSendsVolumeAsStorageID(t *testing.T) {
}
}

// volume_mode goes in the body only when set: an empty one leaves the API to its default, copy.
func TestCreateInstanceSendsVolumeMode(t *testing.T) {
var bodies []map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
bodies = append(bodies, body)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{"data": Instance{ID: "inst-new"}})
}))
defer server.Close()
client := NewClient("rc_live_test", server.URL)

for _, req := range []CreateInstanceRequest{
{Name: "mounted", GPUType: "H100", StorageID: "vol-1", VolumeMode: "mount_ro"},
{Name: "default", GPUType: "H100", StorageID: "vol-1"},
} {
if _, err := client.CreateInstance(req); err != nil {
t.Fatalf("CreateInstance(%s) error: %v", req.Name, err)
}
}
if got := bodies[0]["volume_mode"]; got != "mount_ro" {
t.Errorf("volume_mode = %v, want mount_ro", got)
}
if _, ok := bodies[1]["volume_mode"]; ok {
t.Errorf("volume_mode sent without being set: %v", bodies[1])
}
}

func TestDeleteInstance(t *testing.T) {
server := mockInstancesServer(t)
defer server.Close()
Expand Down
53 changes: 4 additions & 49 deletions internal/commands/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,45 +241,12 @@ var instancesTypesCmd = &cobra.Command{
// shared by instances create and schedule create.
func addInstanceOnlyFlags(cmd *cobra.Command) {
cmd.Flags().String("type-id", "", "Instance type ID from 'runcrate instances types'")
cmd.Flags().String("volume-mode", "",
"How --volume reaches the instance: copy (the volume is copied to the machine's disk at boot and changes are copied back, the default) or mount_ro (the volume is mounted read-only at /workspace, ready in seconds)")
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")
}

// volumeModes are the values --volume-mode accepts, as the API spells them.
var volumeModes = []string{"copy", "mount_ro"}

// parseVolumeModeFlag checks --volume-mode before any network call: one of volumeModes in any case,
// and only beside --volume. An empty value is the flag left out, so the body carries no volume_mode.
func parseVolumeModeFlag(value, volumeID string) (string, error) {
if value == "" {
return "", nil
}
mode := strings.ToLower(strings.TrimSpace(value))
valid := false
for _, m := range volumeModes {
valid = valid || mode == m
}
if !valid {
return "", fmt.Errorf("invalid --volume-mode %q: use %s", value, strings.Join(volumeModes, " or "))
}
if volumeID == "" {
return "", fmt.Errorf("--volume-mode needs --volume")
}
return mode, nil
}

// volumeLine is the Volume line of the create output: the mode is named only when the user chose one.
func volumeLine(volumeID, volumeMode string) string {
if volumeMode == "" {
return fmt.Sprintf(" Volume: %s, at /workspace", volumeID)
}
return fmt.Sprintf(" Volume: %s, at /workspace (%s)", volumeID, volumeMode)
}

// buildInstanceRequest turns the create flags (name, gpu, gpu-count, region,
// template, type-id, volume, volume-mode, max-runtime, idle-stop) into the create body.
// template, type-id, volume, max-runtime, idle-stop) into the create body.
func buildInstanceRequest(cmd *cobra.Command) (api.CreateInstanceRequest, error) {
var req api.CreateInstanceRequest
name, _ := cmd.Flags().GetString("name")
Expand All @@ -289,7 +256,6 @@ func buildInstanceRequest(cmd *cobra.Command) (api.CreateInstanceRequest, error)
template, _ := cmd.Flags().GetString("template")
typeID, _ := cmd.Flags().GetString("type-id")
volumeID, _ := cmd.Flags().GetString("volume")
volumeModeRaw, _ := cmd.Flags().GetString("volume-mode")
maxRuntimeRaw, _ := cmd.Flags().GetString("max-runtime")
idleStopRaw, _ := cmd.Flags().GetString("idle-stop")

Expand All @@ -302,11 +268,6 @@ func buildInstanceRequest(cmd *cobra.Command) (api.CreateInstanceRequest, error)
return req, err
}

volumeMode, err := parseVolumeModeFlag(volumeModeRaw, volumeID)
if err != nil {
return req, err
}

gpuType, gpuCount, err := parseGPUFlag(gpuRaw, gpuCountRaw, cmd.Flags().Changed("gpu-count"))
if err != nil {
return req, err
Expand Down Expand Up @@ -336,7 +297,6 @@ func buildInstanceRequest(cmd *cobra.Command) (api.CreateInstanceRequest, error)
MaxRuntimeMinutes: maxRuntime,
IdleShutdownMinutes: idleStop,
StorageID: volumeID,
VolumeMode: volumeMode,
}, nil
}

Expand All @@ -347,13 +307,8 @@ var instancesCreateCmd = &cobra.Command{

For unattended or agent-run work, set --max-runtime and --idle-stop so the instance
does not keep billing after the job ends. Auto stop terminates the instance, so keep
anything you need on a storage volume.

With --volume, --volume-mode mount_ro mounts the volume read-only at /workspace, ready in
seconds, instead of copying it to the machine's disk; write outputs to /root/workspace
(not saved). A machine or volume that cannot mount is refused, never copied instead.`,
Example: ` runcrate instances create --name train --gpu H100 --max-runtime 4h --idle-stop 30m
runcrate instances create --name infer --gpu H100 --volume <volume-id> --volume-mode mount_ro`,
anything you need on a storage volume.`,
Example: " runcrate instances create --name train --gpu H100 --max-runtime 4h --idle-stop 30m",
RunE: func(cmd *cobra.Command, args []string) error {
req, err := buildInstanceRequest(cmd)
if err != nil {
Expand Down Expand Up @@ -398,7 +353,7 @@ seconds, instead of copying it to the machine's disk; write outputs to /root/wor
fmt.Printf(" Max runtime: %s\n", formatOptionalMinutes(instance.MaxRuntimeMinutes))
fmt.Printf(" Idle stop: %s\n", formatOptionalMinutes(instance.IdleShutdownMinutes))
if volumeID != "" {
fmt.Println(volumeLine(volumeID, req.VolumeMode))
fmt.Printf(" Volume: %s, at /workspace\n", volumeID)
}
fmt.Println()
fmt.Println(" Provisioning in the background. Track with:")
Expand Down
157 changes: 7 additions & 150 deletions internal/commands/instances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,7 @@ package commands

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"
)

Expand Down Expand Up @@ -194,15 +189,13 @@ func TestParseAutoStopFlag(t *testing.T) {
// 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(putCreateFlagsBack)
}

func putCreateFlagsBack() {
for _, name := range []string{"name", "gpu", "gpu-count", "type-id", "volume", "volume-mode"} {
f := instancesCreateCmd.Flags().Lookup(name)
f.Value.Set(f.DefValue)
f.Changed = false
}
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
Expand Down Expand Up @@ -241,139 +234,3 @@ func TestBuildInstanceRequestGPUCountBesideTypeID(t *testing.T) {
})
}
}

// --volume-mode is sent as volume_mode only when the user gave it, in the API's spelling whatever the case.
func TestBuildInstanceRequestVolumeMode(t *testing.T) {
tests := []struct {
name string
flags map[string]string
wantMode string
wantSent bool
}{
{"no flag sends no volume_mode", map[string]string{"volume": "vol-1"}, "", false},
{"mount_ro", map[string]string{"volume": "vol-1", "volume-mode": "mount_ro"}, "mount_ro", true},
{"copy", map[string]string{"volume": "vol-1", "volume-mode": "copy"}, "copy", true},
{"any case", map[string]string{"volume": "vol-1", "volume-mode": "Mount_RO"}, "mount_ro", true},
{"spaces around it", map[string]string{"volume": "vol-1", "volume-mode": " COPY "}, "copy", true},
{"an empty value is no flag", map[string]string{"volume": "vol-1", "volume-mode": ""}, "", false},
{"no volume and no flag", map[string]string{}, "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetCreateFlags(t)
instancesCreateCmd.Flags().Set("name", "box")
instancesCreateCmd.Flags().Set("gpu", "H100")
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.VolumeMode != tt.wantMode {
t.Errorf("VolumeMode = %q, want %q", req.VolumeMode, tt.wantMode)
}
body, _ := json.Marshal(req)
var sent map[string]any
json.Unmarshal(body, &sent)
if got, ok := sent["volume_mode"]; ok != tt.wantSent || (ok && got != tt.wantMode) {
t.Errorf("body %s: volume_mode = %v, sent %v, want %q sent %v", body, got, ok, tt.wantMode, tt.wantSent)
}
})
}
}

func TestBuildInstanceRequestVolumeModeRefusals(t *testing.T) {
tests := []struct {
name string
flags map[string]string
wantErr string
}{
{"an unknown mode", map[string]string{"volume": "vol-1", "volume-mode": "rw"}, "copy or mount_ro"},
{"a mode nobody knows without a volume", map[string]string{"volume-mode": "rw"}, "copy or mount_ro"},
{"a mode without a volume", map[string]string{"volume-mode": "mount_ro"}, "--volume-mode needs --volume"},
{"copy without a volume", map[string]string{"volume-mode": "copy"}, "--volume-mode needs --volume"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetCreateFlags(t)
instancesCreateCmd.Flags().Set("name", "box")
instancesCreateCmd.Flags().Set("gpu", "H100")
for name, value := range tt.flags {
instancesCreateCmd.Flags().Set(name, value)
}
_, err := buildInstanceRequest(instancesCreateCmd)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("err = %v, want it to mention %q", err, tt.wantErr)
}
})
}
}

// A refused --volume-mode never reaches the network: the whole command runs against a server that counts
// requests, and a good one sends volume_mode in the create body.
func TestInstancesCreateVolumeModeBeforeAnyCall(t *testing.T) {
var calls atomic.Int32
var created map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/instances":
raw, _ := io.ReadAll(r.Body)
json.Unmarshal(raw, &created)
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"data":{"id":"inst-1","name":"box","gpu_type":"H100","gpu_count":1}}`))
default:
w.Write([]byte(`{"data":[]}`))
}
}))
defer server.Close()
t.Setenv("HOME", t.TempDir())
t.Setenv("RUNCRATE_API_KEY", "rc_live_test")
t.Setenv("RUNCRATE_API_URL", server.URL)

t.Cleanup(putCreateFlagsBack)
run := func(flags map[string]string) error {
putCreateFlagsBack()
instancesCreateCmd.Flags().Set("name", "box")
instancesCreateCmd.Flags().Set("gpu", "H100")
for name, value := range flags {
instancesCreateCmd.Flags().Set(name, value)
}
stdout := os.Stdout
devnull, _ := os.Open(os.DevNull)
os.Stdout = devnull
defer func() { os.Stdout = stdout; devnull.Close() }()
return instancesCreateCmd.RunE(instancesCreateCmd, nil)
}

for _, flags := range []map[string]string{
{"volume": "vol-1", "volume-mode": "rw"},
{"volume-mode": "mount_ro"},
} {
if err := run(flags); err == nil {
t.Errorf("flags %v: expected an error", flags)
}
if n := calls.Load(); n != 0 {
t.Fatalf("flags %v: %d requests were made before the refusal", flags, n)
}
}

if err := run(map[string]string{"volume": "vol-1", "volume-mode": "mount_ro"}); err != nil {
t.Fatalf("create with mount_ro: %v", err)
}
if created["storage_id"] != "vol-1" || created["volume_mode"] != "mount_ro" {
t.Errorf("create body = %v, want storage_id vol-1 and volume_mode mount_ro", created)
}
}

func TestVolumeLine(t *testing.T) {
if got, want := volumeLine("vol-1", ""), " Volume: vol-1, at /workspace"; got != want {
t.Errorf("without a mode = %q, want %q", got, want)
}
if got, want := volumeLine("vol-1", "mount_ro"), " Volume: vol-1, at /workspace (mount_ro)"; got != want {
t.Errorf("with a mode = %q, want %q", 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", "volume-mode"}
instanceOnlyScheduleFlags = []string{"type-id", "idle-stop"}
jobOnlyScheduleFlags = []string{"image", "env"}
)

Expand Down
Loading
Loading