From d38156399b795c488059ea3aa7d8f450feddcc27 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 26 Aug 2026 15:19:06 -0400 Subject: [PATCH 1/2] fix(pod): remove --stop-after and --terminate-after Both flags were forwarded to podFindAndDeployOnDemand, which accepts stopAfter/terminateAfter and never acts on them: the pod keeps running, and billing, past the deadline. The value is also not readable back from the Pod type or REST v2, so the cli cannot detect the failure after the fact either. Nothing a client can do makes the timer fire, and a flag that silently costs money is worse than no flag, so both are gone. Passing one is now a usage error. Also drops StopAfter/TerminateAfter from CreatePodGQLInput and duration.ParseDeadline, which had no other caller. --- cmd/pod/create.go | 12 ------------ docs/runpodctl_pod_create.md | 2 -- internal/api/graphql.go | 2 -- 3 files changed, 16 deletions(-) diff --git a/cmd/pod/create.go b/cmd/pod/create.go index 235755c7..be49f61c 100644 --- a/cmd/pod/create.go +++ b/cmd/pod/create.go @@ -68,8 +68,6 @@ var ( createDockerArgs string createRegistryAuthID string createCountryCode string - createStopAfter string - createTerminateAfter string createCompliance string createWait bool createWaitTimeout string @@ -97,8 +95,6 @@ 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(&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") @@ -448,14 +444,6 @@ func createPodGraphQL(gpuTypeID, cloudType string, supportPublicIP bool) (map[st req.CountryCode = createCountryCode } - if createStopAfter != "" { - req.StopAfter = createStopAfter - } - - if createTerminateAfter != "" { - req.TerminateAfter = createTerminateAfter - } - if createCompliance != "" { req.Compliance = strings.Split(createCompliance, ",") } diff --git a/docs/runpodctl_pod_create.md b/docs/runpodctl_pod_create.md index 6fb02fd2..7de20d40 100644 --- a/docs/runpodctl_pod_create.md +++ b/docs/runpodctl_pod_create.md @@ -52,9 +52,7 @@ runpodctl pod create [flags] --public-ip require public ip (community cloud only) --registry-auth-id string container registry auth id (from 'runpodctl registry list') --ssh enable ssh on the pod (default true) - --stop-after string auto-stop datetime (e.g., 2026-04-15T00:00:00Z) --template-id string template id (use 'runpodctl template search' to find templates) - --terminate-after string auto-terminate datetime (e.g., 2026-04-15T00:00:00Z) --volume-in-gb int volume size in gb --volume-mount-path string volume mount path (default "/workspace") --wait 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 diff --git a/internal/api/graphql.go b/internal/api/graphql.go index d88a6fd4..6c1b8037 100644 --- a/internal/api/graphql.go +++ b/internal/api/graphql.go @@ -324,8 +324,6 @@ type CreatePodGQLInput struct { DockerArgs string `json:"dockerArgs,omitempty"` ContainerRegistryAuthId string `json:"containerRegistryAuthId,omitempty"` CountryCode string `json:"countryCode,omitempty"` - StopAfter string `json:"stopAfter,omitempty"` - TerminateAfter string `json:"terminateAfter,omitempty"` Compliance []string `json:"compliance,omitempty"` } From 9188ca5cb61010f8335234a1d58df2eeae061ef7 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 26 Aug 2026 15:29:15 -0400 Subject: [PATCH 2/2] fix(pod): restore --stop-after and --terminate-after, validated Brings back the two flags with every client-side way of losing the deadline closed. Merge only once the api enforces stopAfter and terminateAfter; until then their removal is the correct behaviour. - duration.ParseDeadline accepts a duration (2h, 7d) or an rfc3339 time and rejects the past, so the documented duration form works instead of hitting the graphql DateTime scalar. - Both flags resolve before the create call, so a bad value never costs a billed pod. - CPU pods error rather than dropping the flag: they are created over rest v2, which has no scheduling field. - The resolved deadline is echoed to stderr, since neither the create response nor a pod read exposes it. --- cmd/pod/create.go | 75 ++++++++++++++++++++- cmd/pod/create_test.go | 103 +++++++++++++++++++++++++++++ docs/runpodctl_pod_create.md | 2 + internal/api/graphql.go | 2 + internal/duration/duration.go | 27 ++++++++ internal/duration/duration_test.go | 37 +++++++++++ 6 files changed, 244 insertions(+), 2 deletions(-) diff --git a/cmd/pod/create.go b/cmd/pod/create.go index be49f61c..ff3e05db 100644 --- a/cmd/pod/create.go +++ b/cmd/pod/create.go @@ -68,6 +68,8 @@ var ( createDockerArgs string createRegistryAuthID string createCountryCode string + createStopAfter string + createTerminateAfter string createCompliance string createWait bool createWaitTimeout string @@ -95,6 +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 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") @@ -164,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" { @@ -171,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 { @@ -239,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() } @@ -391,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 @@ -444,6 +512,9 @@ func createPodGraphQL(gpuTypeID, cloudType string, supportPublicIP bool) (map[st req.CountryCode = createCountryCode } + 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/docs/runpodctl_pod_create.md b/docs/runpodctl_pod_create.md index 7de20d40..6fb02fd2 100644 --- a/docs/runpodctl_pod_create.md +++ b/docs/runpodctl_pod_create.md @@ -52,7 +52,9 @@ runpodctl pod create [flags] --public-ip require public ip (community cloud only) --registry-auth-id string container registry auth id (from 'runpodctl registry list') --ssh enable ssh on the pod (default true) + --stop-after string auto-stop datetime (e.g., 2026-04-15T00:00:00Z) --template-id string template id (use 'runpodctl template search' to find templates) + --terminate-after string auto-terminate datetime (e.g., 2026-04-15T00:00:00Z) --volume-in-gb int volume size in gb --volume-mount-path string volume mount path (default "/workspace") --wait 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 diff --git a/internal/api/graphql.go b/internal/api/graphql.go index 6c1b8037..d88a6fd4 100644 --- a/internal/api/graphql.go +++ b/internal/api/graphql.go @@ -324,6 +324,8 @@ type CreatePodGQLInput struct { DockerArgs string `json:"dockerArgs,omitempty"` ContainerRegistryAuthId string `json:"containerRegistryAuthId,omitempty"` CountryCode string `json:"countryCode,omitempty"` + StopAfter string `json:"stopAfter,omitempty"` + TerminateAfter string `json:"terminateAfter,omitempty"` Compliance []string `json:"compliance,omitempty"` } 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) + } + } +}