diff --git a/cmd/pod/create.go b/cmd/pod/create.go index 235755c7..ff3e05db 100644 --- a/cmd/pod/create.go +++ b/cmd/pod/create.go @@ -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") @@ -168,6 +168,11 @@ 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" { @@ -175,7 +180,7 @@ func runCreate(cmd *cobra.Command, args []string) error { 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 { @@ -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() } @@ -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 @@ -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, ",") diff --git a/cmd/pod/create_test.go b/cmd/pod/create_test.go index 9b9efd3b..3d7fa3cb 100644 --- a/cmd/pod/create_test.go +++ b/cmd/pod/create_test.go @@ -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 + } +} diff --git a/internal/duration/duration.go b/internal/duration/duration.go index d797aeda..7e863bb1 100644 --- a/internal/duration/duration.go +++ b/internal/duration/duration.go @@ -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 +} diff --git a/internal/duration/duration_test.go b/internal/duration/duration_test.go index 922c6e4f..0254a804 100644 --- a/internal/duration/duration_test.go +++ b/internal/duration/duration_test.go @@ -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) + } + } +}