Skip to content
Draft
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
81 changes: 70 additions & 11 deletions cmd/pod/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ func init() {
createCmd.Flags().StringVar(&createDockerArgs, "docker-args", "", "docker cmd arguments")
createCmd.Flags().StringVar(&createRegistryAuthID, "registry-auth-id", "", "container registry auth id (from 'runpodctl registry list')")
createCmd.Flags().StringVar(&createCountryCode, "country-code", "", "limit pod to a specific country (e.g., US, DE)")
createCmd.Flags().StringVar(&createStopAfter, "stop-after", "", "auto-stop datetime (e.g., 2026-04-15T00:00:00Z)")
createCmd.Flags().StringVar(&createTerminateAfter, "terminate-after", "", "auto-terminate datetime (e.g., 2026-04-15T00:00:00Z)")
createCmd.Flags().StringVar(&createStopAfter, "stop-after", "", "auto-stop the pod after a duration (2h, 7d) or at an rfc3339 time (2026-04-15T00:00:00Z); gpu pods only")
createCmd.Flags().StringVar(&createTerminateAfter, "terminate-after", "", "auto-terminate the pod after a duration (2h, 7d) or at an rfc3339 time (2026-04-15T00:00:00Z); gpu pods only")
createCmd.Flags().StringVar(&createCompliance, "compliance", "", "comma-separated compliance requirements (e.g., HIPAA,SOC_2_TYPE_2)")
createCmd.Flags().BoolVar(&createWait, "wait", false, "block until ssh is reachable (tcp connect to the pod's public port 22 answers with an ssh banner; no key or handshake needed), then print the pod as 'pod get' does. needs a publicly mapped port 22, so community cloud also needs --public-ip")
createCmd.Flags().StringVar(&createWaitTimeout, "wait-timeout", defaultWaitTimeout, "max time to wait with --wait, e.g. 90s, 10m, 1h; on timeout the pod is kept and the error carries its id")
Expand Down Expand Up @@ -168,14 +168,19 @@ func runCreate(cmd *cobra.Command, args []string) error {
return err
}

schedule, err := resolvePodSchedule(cmd, computeType)
if err != nil {
return err
}

var result interface{}

if computeType == "CPU" {
// CPU pods use the REST API (GraphQL requires gpuTypeId)
result, err = createPodREST(computeType, gpuTypeID, cloudType, supportPublicIP)
} else {
// GPU pods use GraphQL (supports startSsh)
result, err = createPodGraphQL(gpuTypeID, cloudType, supportPublicIP)
result, err = createPodGraphQL(gpuTypeID, cloudType, supportPublicIP, schedule)
}
if err != nil {
if createGlobalNetworking {
Expand Down Expand Up @@ -243,6 +248,65 @@ func resolveWaitTimeout(cmd *cobra.Command, computeType, cloudType string, suppo
return timeout, nil
}

// podSchedule carries the auto-stop / auto-terminate deadlines as the rfc3339
// strings the graphql api takes. An empty field means "not requested".
type podSchedule struct {
stopAfter string
terminateAfter string
}

// timeNow is an injection point so the schedule tests do not read the clock.
var timeNow = time.Now

// resolvePodSchedule validates --stop-after / --terminate-after and normalises
// them to absolute instants. It runs before the create call so a rejected value
// costs nothing.
func resolvePodSchedule(cmd *cobra.Command, computeType string) (podSchedule, error) {
stopAfter, err := resolveDeadlineFlag("stop-after", createStopAfter, computeType)
if err != nil {
return podSchedule{}, err
}
terminateAfter, err := resolveDeadlineFlag("terminate-after", createTerminateAfter, computeType)
if err != nil {
return podSchedule{}, err
}

// Neither the create response nor a pod read exposes these, so the note is
// the only echo the caller gets of the deadline that was sent. Do not
// restore these flags before the api enforces the timer: telling someone a
// pod will stop when it will not is how the flags got removed in the first
// place.
if stopAfter != "" {
fmt.Fprintf(cmd.ErrOrStderr(), "note: auto-stop scheduled for %s\n", stopAfter)
}
if terminateAfter != "" {
fmt.Fprintf(cmd.ErrOrStderr(), "note: auto-terminate scheduled for %s\n", terminateAfter)
}

return podSchedule{stopAfter: stopAfter, terminateAfter: terminateAfter}, nil
}

// resolveDeadlineFlag turns one scheduling flag into an rfc3339 instant, or ""
// when it was not set.
//
// CPU pods are refused: they are created over the rest api, whose schema has no
// scheduling field, so the flag used to be dropped without a word and the pod
// ran until someone noticed the bill.
func resolveDeadlineFlag(flag, value, computeType string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
if computeType == "CPU" {
return "", fmt.Errorf("--%s is not supported for compute type CPU; cpu pods are created through the rest api, which has no auto-stop or auto-terminate field", flag)
}
at, err := duration.ParseDeadline(value, timeNow())
if err != nil {
return "", fmt.Errorf("invalid --%s: %w", flag, err)
}
return at.Format(time.RFC3339), nil
}

// injection points for the wait, so its tests neither sleep nor hit the network.
var (
newPodWaitLister = func() (waitfor.PodLister, error) { return api.NewGraphQLClient() }
Expand Down Expand Up @@ -395,7 +459,7 @@ func podIDFrom(result interface{}) (string, error) {
return "", fmt.Errorf("pod was created but the response carried no id, so --wait cannot poll it; find it with 'runpodctl pod list'")
}

func createPodGraphQL(gpuTypeID, cloudType string, supportPublicIP bool) (map[string]interface{}, error) {
func createPodGraphQL(gpuTypeID, cloudType string, supportPublicIP bool, schedule podSchedule) (map[string]interface{}, error) {
gqlClient, err := api.NewGraphQLClient()
if err != nil {
return nil, err
Expand Down Expand Up @@ -448,13 +512,8 @@ func createPodGraphQL(gpuTypeID, cloudType string, supportPublicIP bool) (map[st
req.CountryCode = createCountryCode
}

if createStopAfter != "" {
req.StopAfter = createStopAfter
}

if createTerminateAfter != "" {
req.TerminateAfter = createTerminateAfter
}
req.StopAfter = schedule.stopAfter
req.TerminateAfter = schedule.terminateAfter

if createCompliance != "" {
req.Compliance = strings.Split(createCompliance, ",")
Expand Down
103 changes: 103 additions & 0 deletions cmd/pod/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -711,3 +711,106 @@ func TestPodCreateRequestDockerArgsWireFormat(t *testing.T) {
t.Fatalf("request body missing dockerStartCmd tokens: %s", body)
}
}

func TestResolvePodSchedule(t *testing.T) {
now := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)

cases := []struct {
name string
computeType string
stopAfter string
terminateAfter string
wantStop string
wantTerminate string
wantErr string
}{
{name: "unset", computeType: "GPU"},
{
name: "duration is resolved against now",
computeType: "GPU",
stopAfter: "2h",
wantStop: "2026-04-15T14:00:00Z",
},
{
name: "days are supported",
computeType: "GPU",
terminateAfter: "7d",
wantTerminate: "2026-04-22T12:00:00Z",
},
{
name: "rfc3339 is normalised to utc",
computeType: "GPU",
stopAfter: "2026-04-15T16:30:00+02:00",
wantStop: "2026-04-15T14:30:00Z",
},
{
name: "past timestamp is rejected",
computeType: "GPU",
stopAfter: "2020-01-01T00:00:00Z",
wantErr: "must be in the future",
},
{
name: "garbage is rejected",
computeType: "GPU",
stopAfter: "tomorrow",
wantErr: "invalid --stop-after",
},
{
name: "cpu pods are refused rather than silently dropped",
computeType: "CPU",
stopAfter: "2h",
wantErr: "not supported for compute type CPU",
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
defer withScheduleFlags(t, tc.stopAfter, tc.terminateAfter, now)()

cmd := &cobra.Command{}
cmd.SetErr(&bytes.Buffer{})

got, err := resolvePodSchedule(cmd, tc.computeType)
if tc.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("err = %v, want it to contain %q", err, tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.stopAfter != tc.wantStop || got.terminateAfter != tc.wantTerminate {
t.Fatalf("got %+v, want stop=%q terminate=%q", got, tc.wantStop, tc.wantTerminate)
}
})
}
}

func TestResolvePodScheduleNotesTheDeadline(t *testing.T) {
now := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)
defer withScheduleFlags(t, "2h", "", now)()

var stderr bytes.Buffer
cmd := &cobra.Command{}
cmd.SetErr(&stderr)

if _, err := resolvePodSchedule(cmd, "GPU"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stderr.String(), "auto-stop scheduled for 2026-04-15T14:00:00Z") {
t.Fatalf("stderr = %q, want the resolved stop time", stderr.String())
}
}

// withScheduleFlags sets the create flags and clock resolvePodSchedule reads,
// and returns the restore func.
func withScheduleFlags(t *testing.T, stopAfter, terminateAfter string, now time.Time) func() {
t.Helper()
prevStop, prevTerminate, prevNow := createStopAfter, createTerminateAfter, timeNow
createStopAfter, createTerminateAfter = stopAfter, terminateAfter
timeNow = func() time.Time { return now }
return func() {
createStopAfter, createTerminateAfter, timeNow = prevStop, prevTerminate, prevNow
}
}
27 changes: 27 additions & 0 deletions internal/duration/duration.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,30 @@ func Parse(s string) (time.Duration, error) {
}
return d, nil
}

// ParseDeadline resolves a flag value that names a point in time into an
// absolute UTC instant. It accepts both forms users reach for: a relative
// duration ("2h", "7d") and an RFC 3339 timestamp ("2026-04-15T00:00:00Z").
//
// Past instants are rejected. The pod scheduler accepts them and then never
// fires, which reads as a timer that was silently ignored -- the exact failure
// this validation exists to prevent.
func ParseDeadline(s string, now time.Time) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, fmt.Errorf("empty value: use a duration like 2h or an rfc3339 timestamp like 2026-04-15T00:00:00Z")
}

if d, err := Parse(s); err == nil {
return now.UTC().Add(d), nil
}

t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}, fmt.Errorf("invalid time %q: use a duration like 2h, 7d or an rfc3339 timestamp like 2026-04-15T00:00:00Z", s)
}
if !t.After(now) {
return time.Time{}, fmt.Errorf("invalid time %q: must be in the future (now is %s)", s, now.UTC().Format(time.RFC3339))
}
return t.UTC(), nil
}
37 changes: 37 additions & 0 deletions internal/duration/duration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,40 @@ func TestParse(t *testing.T) {
})
}
}

func TestParseDeadline(t *testing.T) {
now := time.Date(2026, 4, 15, 12, 0, 0, 0, time.UTC)

cases := []struct {
in string
want string
wantErr bool
}{
{in: "30m", want: "2026-04-15T12:30:00Z"},
{in: "7d", want: "2026-04-22T12:00:00Z"},
{in: " 2h ", want: "2026-04-15T14:00:00Z"},
{in: "2026-04-15T16:30:00+02:00", want: "2026-04-15T14:30:00Z"},
{in: "", wantErr: true},
{in: "tomorrow", wantErr: true},
{in: "-2h", wantErr: true},
{in: "2020-01-01T00:00:00Z", wantErr: true},
{in: "2026-04-15T12:00:00Z", wantErr: true}, // now is not in the future
}

for _, tc := range cases {
got, err := ParseDeadline(tc.in, now)
if tc.wantErr {
if err == nil {
t.Errorf("ParseDeadline(%q) = %v, want error", tc.in, got)
}
continue
}
if err != nil {
t.Errorf("ParseDeadline(%q) errored: %v", tc.in, err)
continue
}
if got.Format(time.RFC3339) != tc.want {
t.Errorf("ParseDeadline(%q) = %s, want %s", tc.in, got.Format(time.RFC3339), tc.want)
}
}
}
Loading