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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,39 @@ runcrate jobs cancel <id>
own code, 124 on timeout, 130 when cancelled. Ctrl-C detaches and leaves the job
running; `-d` / `--detach` prints the job ID and returns.

## Schedules

A schedule starts an instance or a job later: once with `--at`, or on a recurring
5-field `--cron`. A command after `--` makes it a job (same flags as `runcrate run`);
without one it starts an instance (same flags as `runcrate instances create`, so
`--name` is required). `--max-runtime` is required for both, so nothing a schedule
starts keeps running unattended.

```bash
runcrate schedule create --cron '0 2 * * *' --tz Europe/London --name nightly-train \
--gpu H100 --volume <volume-id> --max-runtime 2h -- python train.py
runcrate schedule create --at '2026-10-21 09:00' --name morning-box \
--gpu A4000 --max-runtime 8h --idle-stop 30m
runcrate schedule list [-q] [--json] # when, next run (your local time), last result
runcrate schedule get <id> # details, last run, last error
runcrate schedule pause <id> # resume <id> turns it back on
runcrate schedule run-now <id> # start it now; the next run stays as it is
runcrate schedule delete <id> [--yes] # what it already started keeps running
```

- `--at '2026-10-21 02:00'` is the wall time in `--tz`; a time with an offset
(`2026-10-21T02:00:00+01:00`) is used as is. `--tz` takes an IANA name and
defaults to this machine's timezone, for `--cron` too.
- Across daylight saving: a time the clocks skip moves forward by the gap (02:30
becomes 03:30), and a time that happens twice runs the first time.
- Recurring runs are at least 15 minutes apart. `--overlap skip` (the default)
skips a run while the previous one is still going; `--overlap allow` starts it.
- Runs start within about 2 minutes of their time, as you, in the current
environment. While the environment's monthly budget is reached, runs are skipped.
- A schedule set up with an API key or an agent sign-in launches with it; when
that key is revoked or the sign-in ends, the schedule pauses (last result
`paused: key revoked`).

## Updating

```bash
Expand Down
141 changes: 141 additions & 0 deletions internal/api/schedules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package api

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/url"
)

// Schedule starts an instance or a job later: once at RunAt, or on a 5-field
// Cron in Timezone.
type Schedule struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"` // "instance" or "job"
EnvironmentID string `json:"environment_id"`
CreatedBy string `json:"created_by"`
// Spec is the create body of the kind. Env values are never returned, only
// their names under env_keys.
Spec map[string]any `json:"spec"`
MaxRuntimeMinutes int `json:"max_runtime_minutes"`
RunAt *string `json:"run_at"`
Cron *string `json:"cron"`
Timezone string `json:"timezone"`
Overlap string `json:"overlap"`
Enabled bool `json:"enabled"`
NextRunAt *string `json:"next_run_at"`
LastRunAt *string `json:"last_run_at"`
// LastStatus is launched, failed, skipped_budget, skipped_overlap,
// no_access or credential_revoked.
LastStatus *string `json:"last_status"`
LastError *string `json:"last_error"`
// RunAs is the user each run launches as; RunAsCredential is how the
// schedule was set up: user, api_key or agent.
RunAs *string `json:"run_as"`
RunAsCredential string `json:"run_as_credential"`
LastInstanceID *string `json:"last_instance_id"`
LastJobID *string `json:"last_job_id"`
RunCount int `json:"run_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}

// CreateScheduleRequest is the body of POST /api/v1/schedules. Spec is a
// CreateInstanceRequest or a CreateJobRequest; set exactly one of RunAt and Cron.
type CreateScheduleRequest struct {
Kind string `json:"kind"`
Spec any `json:"spec"`
RunAt string `json:"run_at,omitempty"`
Cron string `json:"cron,omitempty"`
Timezone string `json:"timezone,omitempty"`
Overlap string `json:"overlap,omitempty"`
Name string `json:"name,omitempty"`
}

// UpdateScheduleRequest is the body of PATCH /api/v1/schedules/{id}. Nil
// fields are left as they are.
type UpdateScheduleRequest struct {
Enabled *bool `json:"enabled,omitempty"`
}

// ScheduleRun is what run-now started: Instance or Job, by the schedule's kind.
type ScheduleRun struct {
Schedule Schedule `json:"schedule"`
Instance *Instance `json:"instance,omitempty"`
Job *Job `json:"job,omitempty"`
}

// DeletedSchedule is the response of DELETE /api/v1/schedules/{id}.
type DeletedSchedule struct {
ID string `json:"id"`
Deleted bool `json:"deleted"`
}

// CreateSchedule creates a schedule in the current environment.
func (c *Client) CreateSchedule(req CreateScheduleRequest) (*Schedule, error) {
return callJSON[Schedule](c, "POST", "/api/v1/schedules", req)
}

// ListSchedules returns the environment's schedules, newest first.
func (c *Client) ListSchedules() ([]Schedule, error) {
list, err := callJSON[[]Schedule](c, "GET", "/api/v1/schedules", nil)
if err != nil {
return nil, err
}
return *list, nil
}

// GetSchedule returns one schedule.
func (c *Client) GetSchedule(id string) (*Schedule, error) {
return callJSON[Schedule](c, "GET", schedulePath(id), nil)
}

// UpdateSchedule changes a schedule; enabled false pauses it, true resumes it.
func (c *Client) UpdateSchedule(id string, req UpdateScheduleRequest) (*Schedule, error) {
return callJSON[Schedule](c, "PATCH", schedulePath(id), req)
}

// DeleteSchedule removes a schedule. What it already started keeps running.
func (c *Client) DeleteSchedule(id string) (*DeletedSchedule, error) {
return callJSON[DeletedSchedule](c, "DELETE", schedulePath(id), nil)
}

// RunScheduleNow launches the schedule's instance or job immediately, whether
// it is enabled or not, without moving its next run.
func (c *Client) RunScheduleNow(id string) (*ScheduleRun, error) {
return callJSON[ScheduleRun](c, "POST", schedulePath(id)+"/run-now", nil)
}

func schedulePath(id string) string {
return "/api/v1/schedules/" + url.PathEscape(id)
}

// callJSON sends body (nil for none) as JSON and decodes the { data: T } reply.
func callJSON[T any](c *Client, method, path string, body any) (*T, error) {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("encoding request: %w", err)
}
reader = bytes.NewReader(raw)
}

resp, err := c.do(method, path, reader)
if err != nil {
return nil, fmt.Errorf("network error: %w", err)
}
defer resp.Body.Close()

if err := checkHTTPStatus(resp); err != nil {
return nil, err
}

var result apiEnvelope[T]
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
return &result.Data, nil
}
8 changes: 7 additions & 1 deletion internal/commands/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"runtime"
Expand Down Expand Up @@ -41,7 +42,12 @@ func loadClient() (*api.Client, error) {

// newTable creates a tabwriter for formatted table output.
func newTable() *tabwriter.Writer {
return tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
return newTableTo(os.Stdout)
}

// newTableTo is newTable writing to w.
func newTableTo(w io.Writer) *tabwriter.Writer {
return tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
}

// printJSON outputs data as indented JSON.
Expand Down
105 changes: 61 additions & 44 deletions internal/commands/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,62 @@ var instancesTypesCmd = &cobra.Command{

// ── create ──────────────────────────────────────────────────────────────────

// addInstanceOnlyFlags registers the create flags that only an instance takes,
// 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("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.
func buildInstanceRequest(cmd *cobra.Command) (api.CreateInstanceRequest, error) {
var req api.CreateInstanceRequest
name, _ := cmd.Flags().GetString("name")
gpuRaw, _ := cmd.Flags().GetString("gpu")
gpuCountRaw, _ := cmd.Flags().GetInt("gpu-count")
region, _ := cmd.Flags().GetString("region")
template, _ := cmd.Flags().GetString("template")
typeID, _ := cmd.Flags().GetString("type-id")
volumeID, _ := cmd.Flags().GetString("volume")
maxRuntimeRaw, _ := cmd.Flags().GetString("max-runtime")
idleStopRaw, _ := cmd.Flags().GetString("idle-stop")

maxRuntime, err := parseMinutesFlag("max-runtime", maxRuntimeRaw, maxRuntimeMaxMinutes)
if err != nil {
return req, err
}
idleStop, err := parseMinutesFlag("idle-stop", idleStopRaw, idleStopMaxMinutes)
if err != nil {
return req, err
}

gpuType, gpuCount, err := parseGPUFlag(gpuRaw, gpuCountRaw, cmd.Flags().Changed("gpu-count"))
if err != nil {
return req, err
}

if name == "" {
return req, fmt.Errorf("--name is required")
}
if gpuType == "" && typeID == "" {
return req, fmt.Errorf("provide --gpu for auto-deploy or --type-id from 'runcrate instances types'")
}

return api.CreateInstanceRequest{
Name: name,
GPUType: gpuType,
GPUCount: gpuCount,
Region: region,
Template: template,
InstanceTypeID: typeID,
MaxRuntimeMinutes: maxRuntime,
IdleShutdownMinutes: idleStop,
StorageID: volumeID,
}, nil
}

var instancesCreateCmd = &cobra.Command{
Use: "create",
Short: "Create and deploy a new instance",
Expand All @@ -247,36 +303,11 @@ does not keep billing after the job ends. Auto stop terminates the instance, so
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 {
name, _ := cmd.Flags().GetString("name")
gpuRaw, _ := cmd.Flags().GetString("gpu")
gpuCountRaw, _ := cmd.Flags().GetInt("gpu-count")
region, _ := cmd.Flags().GetString("region")
template, _ := cmd.Flags().GetString("template")
typeID, _ := cmd.Flags().GetString("type-id")
volumeID, _ := cmd.Flags().GetString("volume")
maxRuntimeRaw, _ := cmd.Flags().GetString("max-runtime")
idleStopRaw, _ := cmd.Flags().GetString("idle-stop")

maxRuntime, err := parseMinutesFlag("max-runtime", maxRuntimeRaw, maxRuntimeMaxMinutes)
if err != nil {
return err
}
idleStop, err := parseMinutesFlag("idle-stop", idleStopRaw, idleStopMaxMinutes)
req, err := buildInstanceRequest(cmd)
if err != nil {
return err
}

gpuType, gpuCount, err := parseGPUFlag(gpuRaw, gpuCountRaw, cmd.Flags().Changed("gpu-count"))
if err != nil {
return err
}

if name == "" {
return fmt.Errorf("--name is required")
}
if gpuType == "" && typeID == "" {
return fmt.Errorf("provide --gpu for auto-deploy or --type-id from 'runcrate instances types'")
}
volumeID := req.StorageID

client, err := loadClient()
if err != nil {
Expand All @@ -286,23 +317,11 @@ anything you need on a storage volume.`,
// Check for duplicate active instance name
existing, _ := client.ListInstances("")
for _, inst := range existing {
if strings.EqualFold(inst.Name, name) && isActiveInstance(inst.Status) {
return fmt.Errorf("instance %q already exists (ID: %s, status: %s). Use a different name or delete it first", name, inst.ID, inst.Status)
if strings.EqualFold(inst.Name, req.Name) && isActiveInstance(inst.Status) {
return fmt.Errorf("instance %q already exists (ID: %s, status: %s). Use a different name or delete it first", req.Name, inst.ID, inst.Status)
}
}

req := api.CreateInstanceRequest{
Name: name,
GPUType: gpuType,
GPUCount: gpuCount,
Region: region,
Template: template,
InstanceTypeID: typeID,
MaxRuntimeMinutes: maxRuntime,
IdleShutdownMinutes: idleStop,
StorageID: volumeID,
}

instance, err := client.CreateInstance(req)
if api.IsBudgetReached(err) {
return errors.New(budgetReachedMessage)
Expand Down Expand Up @@ -588,12 +607,10 @@ func init() {
instancesCreateCmd.Flags().Int("gpu-count", 1, "Number of GPUs (alternative to colon syntax)")
instancesCreateCmd.Flags().String("region", "", "Preferred region")
instancesCreateCmd.Flags().String("template", "", "OS template")
instancesCreateCmd.Flags().String("type-id", "", "Instance type ID from 'runcrate instances types'")
instancesCreateCmd.Flags().String("volume", "", "Storage volume ID to attach, from 'runcrate volumes list'; its files are at /workspace")
instancesCreateCmd.Flags().String("max-runtime", "",
"Stop the instance automatically after this long, 5m to 168h (e.g. 4h). Recommended for unattended work")
instancesCreateCmd.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")
addInstanceOnlyFlags(instancesCreateCmd)

// auto-stop
instancesAutoStopCmd.Flags().String("max-runtime", "",
Expand Down
13 changes: 10 additions & 3 deletions internal/commands/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,15 @@ func parseEnvFlags(vals []string, lookup func(string) (string, bool)) (map[strin
return env, nil
}

// buildJobRequest turns run's flags and arguments into the create body.
// addJobOnlyFlags registers the flags that only a job takes, shared by run and
// schedule create.
func addJobOnlyFlags(cmd *cobra.Command) {
cmd.Flags().String("image", "", "Container image to run the command in")
cmd.Flags().StringArray("env", nil, "Environment variable KEY=VALUE, or KEY to copy it from your shell (repeatable)")
}

// buildJobRequest turns the job flags (gpu, gpu-count, max-runtime, volume,
// image, env, template, name, region) and the command into the create body.
func buildJobRequest(cmd *cobra.Command, args []string, lookupEnv func(string) (string, bool)) (api.CreateJobRequest, error) {
var req api.CreateJobRequest

Expand Down Expand Up @@ -168,8 +176,7 @@ running.`,
cmd.Flags().String("volume", "", "Volume ID to mount as /workspace")
cmd.Flags().String("max-runtime", formatMinutes(defaultMaxRuntimeMinutes), "Stop the job after this long: 90m, 2h, or minutes (1m to 24h)")
cmd.Flags().String("template", "", "OS template (default ubuntu-devbox)")
cmd.Flags().String("image", "", "Container image to run the command in")
cmd.Flags().StringArray("env", nil, "Environment variable KEY=VALUE, or KEY to copy it from your shell (repeatable)")
addJobOnlyFlags(cmd)
cmd.Flags().String("name", "", "Job name (default job-<id>)")
cmd.Flags().String("region", "", "Preferred region")
cmd.Flags().BoolP("detach", "d", false, "Print the job ID and return without following its output")
Expand Down
Loading
Loading