Skip to content
Open
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
36 changes: 36 additions & 0 deletions cli/cmd/completion_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,39 @@ func staticValueCompleter(values []string, descKey func(string) string) func(com
return out, nil
}
}

func commaSeparatedValueCompleter(values []string, descKey func(string) string) func(completion.Context) ([]completion.Candidate, error) {
return func(ctx completion.Context) ([]completion.Candidate, error) {
current := completion.CurrentWordPrefix(ctx)
head := ""
prefix := current
if index := strings.LastIndex(current, ","); index >= 0 {
head = current[:index+1]
prefix = current[index+1:]
}
selected := map[string]struct{}{}
for _, value := range strings.Split(strings.TrimSuffix(head, ","), ",") {
value = strings.ToLower(strings.TrimSpace(value))
if value != "" {
selected[value] = struct{}{}
}
}
prefix = strings.ToLower(strings.TrimSpace(prefix))
out := make([]completion.Candidate, 0, len(values))
for _, value := range values {
lower := strings.ToLower(value)
if _, ok := selected[lower]; ok {
continue
}
if prefix != "" && !strings.HasPrefix(lower, prefix) {
continue
}
candidate := completion.Candidate{Value: head + value}
if descKey != nil {
candidate.Description = i18n.T(descKey(value))
}
out = append(out, candidate)
}
return out, nil
}
}
38 changes: 38 additions & 0 deletions cli/cmd/completion_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package cmd

import (
"reflect"
"testing"

"github.com/raids-lab/crater/cli/internal/completion"
)

func TestCommaSeparatedValueCompleter(t *testing.T) {
complete := commaSeparatedValueCompleter([]string{"Running", "Pending", "Failed"}, nil)
candidates, err := complete(completion.Context{
Words: []string{"Running,F"},
Current: 1,
})
if err != nil {
t.Fatalf("completion returned error: %v", err)
}
want := []completion.Candidate{{Value: "Running,Failed"}}
if !reflect.DeepEqual(candidates, want) {
t.Fatalf("candidates = %#v, want %#v", candidates, want)
}
}

func TestCommaSeparatedValueCompleterOmitsSelectedValues(t *testing.T) {
complete := commaSeparatedValueCompleter([]string{"Running", "Pending"}, nil)
candidates, err := complete(completion.Context{
Words: []string{"Running,"},
Current: 1,
})
if err != nil {
t.Fatalf("completion returned error: %v", err)
}
want := []completion.Candidate{{Value: "Running,Pending"}}
if !reflect.DeepEqual(candidates, want) {
t.Fatalf("candidates = %#v, want %#v", candidates, want)
}
}
132 changes: 103 additions & 29 deletions cli/cmd/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,27 +115,34 @@ func readJobListOptions(cmd *cobra.Command, admin bool) (api.JobListOptions, err
username = strings.TrimSpace(username)
search, _ := cmd.Flags().GetString("search")
days, _ := cmd.Flags().GetInt("days")
status, _ := cmd.Flags().GetString("status")
jobType, _ := cmd.Flags().GetString("type")
statuses := readListFlagValues(cmd, "status")
requestedTypes := readListFlagValues(cmd, "type")
schedules := readListFlagValues(cmd, "schedule")
node, _ := cmd.Flags().GetString("node")
interactive, _ := cmd.Flags().GetBool("interactive")
batch, _ := cmd.Flags().GetBool("batch")
issues = append(issues, jobListFilterIssues(cmd)...)
if len(issues) > 0 {
return api.JobListOptions{}, errUsageFromIssues(issues)
}
scheduleTypes := make([]int, 0, len(schedules))
for _, schedule := range normalizeListFlagValues(schedules) {
value, _ := parseJobListScheduleType(schedule)
scheduleTypes = append(scheduleTypes, value)
}
return api.JobListOptions{
ListOptions: listOptions,
All: all,
Admin: admin,
Username: username,
Search: strings.TrimSpace(search),
Days: days,
Status: strings.TrimSpace(status),
JobType: strings.TrimSpace(jobType),
Node: strings.TrimSpace(node),
Interactive: interactive,
Batch: batch,
ListOptions: listOptions,
All: all,
Admin: admin,
Username: username,
Days: days,
Search: strings.TrimSpace(search),
Statuses: normalizeListFlagValues(statuses),
JobTypes: normalizeListFlagValues(requestedTypes),
ScheduleTypes: scheduleTypes,
Node: strings.TrimSpace(node),
Interactive: interactive,
Batch: batch,
}, nil
}

Expand Down Expand Up @@ -1115,8 +1122,10 @@ func filterJobs(cmd *cobra.Command, jobs []api.JobInfo) ([]api.JobInfo, error) {
if err := validateJobListFilters(cmd); err != nil {
return nil, err
}
status, _ := cmd.Flags().GetString("status")
jobType, _ := cmd.Flags().GetString("type")
statuses := readListFlagValues(cmd, "status")
requestedTypes := readListFlagValues(cmd, "type")
statuses = normalizeListFlagValues(statuses)
requestedTypes = normalizeListFlagValues(requestedTypes)
node, _ := cmd.Flags().GetString("node")
owner, _ := cmd.Flags().GetString("owner")
interactive, _ := cmd.Flags().GetBool("interactive")
Expand All @@ -1134,10 +1143,10 @@ func filterJobs(cmd *cobra.Command, jobs []api.JobInfo) ([]api.JobInfo, error) {

out := jobs[:0]
for _, job := range jobs {
if status != "" && job.Status != status {
if len(statuses) > 0 && !slices.Contains(statuses, job.Status) {
continue
}
if jobType != "" && job.JobType != jobType {
if len(requestedTypes) > 0 && !slices.Contains(requestedTypes, job.JobType) {
continue
}
if node != "" && !slices.Contains(job.Nodes, node) {
Expand Down Expand Up @@ -1177,14 +1186,13 @@ func jobListFilterIssues(cmd *cobra.Command) []usageIssue {
days, _ := cmd.Flags().GetInt("days")
search, _ := cmd.Flags().GetString("search")
sortFields, _ := cmd.Flags().GetString("sort")
status, _ := cmd.Flags().GetString("status")
jobType, _ := cmd.Flags().GetString("type")
statuses := readListFlagValues(cmd, "status")
requestedTypes := readListFlagValues(cmd, "type")
schedules := readListFlagValues(cmd, "schedule")
interactive, _ := cmd.Flags().GetBool("interactive")
batch, _ := cmd.Flags().GetBool("batch")
from, _ := cmd.Flags().GetString("from")
to, _ := cmd.Flags().GetString("to")
status = strings.TrimSpace(status)
jobType = strings.TrimSpace(jobType)
issues := []usageIssue{}
if days < -1 {
issues = append(issues, invalidIssue("days", i18n.T("err_invalid_job_days")))
Expand All @@ -1196,11 +1204,23 @@ func jobListFilterIssues(cmd *cobra.Command) []usageIssue {
))
}
issues = append(issues, jobSortIssues(strings.TrimSpace(sortFields))...)
if status != "" && !slices.Contains(jobStatuses, status) {
issues = append(issues, invalidIssue("status", i18n.T("err_invalid_job_status", status)))
issues = append(issues, validateJobListValues("status", statuses)...)
issues = append(issues, validateJobListValues("type", requestedTypes)...)
issues = append(issues, validateJobListValues("schedule", schedules)...)
for _, status := range normalizeListFlagValues(statuses) {
if !slices.Contains(jobStatuses, status) {
issues = append(issues, invalidIssue("status", i18n.T("err_invalid_job_status", status)))
}
}
for _, jobType := range normalizeListFlagValues(requestedTypes) {
if !slices.Contains(jobTypes, jobType) {
issues = append(issues, invalidIssue("type", i18n.T("err_invalid_job_type", jobType)))
}
}
if jobType != "" && !slices.Contains(jobTypes, jobType) {
issues = append(issues, invalidIssue("type", i18n.T("err_invalid_job_type", jobType)))
for _, schedule := range normalizeListFlagValues(schedules) {
if _, ok := parseJobListScheduleType(schedule); !ok {
issues = append(issues, invalidIssue("schedule", i18n.T("err_invalid_job_schedule", schedule)))
}
}
if interactive && batch {
issues = append(issues, invalidIssue("interactive", i18n.T("err_job_interactive_batch_conflict")))
Expand Down Expand Up @@ -1260,6 +1280,58 @@ func jobSortIssues(raw string) []usageIssue {
return issues
}

func readListFlagValues(cmd *cobra.Command, name string) []string {
flag := cmd.Flags().Lookup(name)
if flag == nil {
return nil
}
if flag.Value.Type() == "stringSlice" {
values, _ := cmd.Flags().GetStringSlice(name)
return values
}
value, _ := cmd.Flags().GetString(name)
if strings.TrimSpace(value) == "" {
return nil
}
return []string{value}
}

func validateJobListValues(field string, values []string) []usageIssue {
issues := []usageIssue{}
for _, value := range values {
if strings.TrimSpace(value) == "" {
issues = append(issues, invalidIssue(field, i18n.T("err_job_filter_empty", field)))
}
}
if len(values) > 20 {
issues = append(issues, invalidIssue(field, i18n.T("err_job_filter_too_many", field)))
}
return issues
}

func normalizeListFlagValues(values []string) []string {
normalized := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || slices.Contains(normalized, value) {
continue
}
normalized = append(normalized, value)
}
return normalized
}

func parseJobListScheduleType(raw string) (int, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "normal":
return scheduleNormal, true
case "backfill":
return scheduleBackfill, true
default:
return 0, false
}
}

func parseOptionalTime(value string) (*time.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
Expand Down Expand Up @@ -1506,8 +1578,9 @@ func init() {
jobLsCmd.Flags().String("user", "", "List jobs for a username")
jobLsCmd.Flags().String("search", "", i18n.T("flag_search"))
jobLsCmd.Flags().Int("days", 0, i18n.T("flag_days"))
jobLsCmd.Flags().String("status", "", "Filter by job status")
jobLsCmd.Flags().String("type", "", "Filter by job type")
jobLsCmd.Flags().StringSlice("status", nil, "Filter by job status, repeatable or comma-separated")
jobLsCmd.Flags().StringSlice("type", nil, "Filter by job type, repeatable or comma-separated")
jobLsCmd.Flags().StringSlice("schedule", nil, "Filter by schedule type: normal or backfill")
jobLsCmd.Flags().String("node", "", "Filter by node name")
jobLsCmd.Flags().String("owner", "", "Filter by owner username or display name")
jobLsCmd.Flags().String("from", "", "Filter createdAt from time, RFC3339 or YYYY-MM-DD")
Expand Down Expand Up @@ -1562,8 +1635,9 @@ func init() {
} {
cleanCmd.Flags().BoolP("yes", "y", false, "Run cleanup without confirmation")
}
completion.RegisterFlagValue([]string{"job", "ls"}, "status", staticValueCompleter(jobStatuses, nil))
completion.RegisterFlagValue([]string{"job", "ls"}, "type", staticValueCompleter(jobTypes, nil))
completion.RegisterFlagValue([]string{"job", "ls"}, "status", commaSeparatedValueCompleter(jobStatuses, nil))
completion.RegisterFlagValue([]string{"job", "ls"}, "type", commaSeparatedValueCompleter(jobTypes, nil))
completion.RegisterFlagValue([]string{"job", "ls"}, "schedule", commaSeparatedValueCompleter([]string{"normal", "backfill"}, nil))
completion.RegisterFlagValue([]string{"job", "pods"}, "status", staticValueCompleter(podStatuses, nil))
completion.RegisterFlagValue([]string{"admin", "job", "ls"}, "status", staticValueCompleter(jobStatuses, nil))
completion.RegisterFlagValue([]string{"admin", "job", "ls"}, "type", staticValueCompleter(jobTypes, nil))
Expand Down
67 changes: 62 additions & 5 deletions cli/cmd/job_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"os"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -90,16 +91,18 @@ func TestValidMountPath(t *testing.T) {
func TestJobListFilterIssuesAggregate(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().Int("days", 0, "")
cmd.Flags().String("status", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().StringSlice("status", nil, "")
cmd.Flags().StringSlice("type", nil, "")
cmd.Flags().StringSlice("schedule", nil, "")
cmd.Flags().Bool("interactive", false, "")
cmd.Flags().Bool("batch", false, "")
cmd.Flags().String("from", "", "")
cmd.Flags().String("to", "", "")
for name, value := range map[string]string{
"days": "-2",
"status": "invalid",
"status": "invalid,also-invalid",
"type": "invalid",
"schedule": "invalid",
"interactive": "true",
"batch": "true",
"from": "2026-07-12",
Expand All @@ -110,7 +113,61 @@ func TestJobListFilterIssuesAggregate(t *testing.T) {
}
}
issues := jobListFilterIssues(cmd)
if len(issues) != 5 {
t.Fatalf("issues = %#v, want 5 aggregated issues", issues)
if len(issues) != 7 {
t.Fatalf("issues = %#v, want 7 aggregated issues", issues)
}
}

func TestNormalizeListFlagValuesTrimsAndDeduplicates(t *testing.T) {
got := normalizeListFlagValues([]string{" Running ", "Pending", "Running", ""})
want := []string{"Running", "Pending"}
if !slices.Equal(got, want) {
t.Fatalf("normalizeListFlagValues() = %#v, want %#v", got, want)
}
}

func TestJobListFilterIssuesEnforcesBackendLimits(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().Int("days", 0, "")
cmd.Flags().String("search", "", "")
cmd.Flags().StringSlice("status", nil, "")
cmd.Flags().StringSlice("type", nil, "")
cmd.Flags().StringSlice("schedule", nil, "")
cmd.Flags().Bool("interactive", false, "")
cmd.Flags().Bool("batch", false, "")
cmd.Flags().String("from", "", "")
cmd.Flags().String("to", "", "")

tooMany := make([]string, 21)
for index := range tooMany {
tooMany[index] = jobStatuses[index%len(jobStatuses)]
}
if err := cmd.Flags().Set("status", strings.Join(tooMany, ",")); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("search", strings.Repeat("界", 129)); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("schedule", "0"); err != nil {
t.Fatal(err)
}

issues := jobListFilterIssues(cmd)
if len(issues) != 3 {
t.Fatalf("issues = %#v, want search, status count, and schedule errors", issues)
}
}

func TestParseJobListScheduleTypeUsesNamesOnly(t *testing.T) {
for input, want := range map[string]int{"normal": scheduleNormal, "Backfill": scheduleBackfill} {
got, ok := parseJobListScheduleType(input)
if !ok || got != want {
t.Fatalf("parseJobListScheduleType(%q) = (%d, %t), want (%d, true)", input, got, ok, want)
}
}
for _, input := range []string{"", "0", "1", "invalid"} {
if _, ok := parseJobListScheduleType(input); ok {
t.Fatalf("parseJobListScheduleType(%q) unexpectedly succeeded", input)
}
}
}
5 changes: 3 additions & 2 deletions cli/docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,9 @@ CLI 发出的平台请求带 `User-Agent: crater-cli/<product-version>` 与 `X-C
- `--user` (string): 调用 `/api/v1/vcjobs/user/{username}`,列出指定用户且位于 `--days` 回看窗口内的作业。
- `--days` (int): 覆盖当前路由的回溯天数;`-1` 表示不按时间过滤。小于 `-1` 的值返回 `usage_error`。不指定时,默认自视图不限制时间,`--all`/管理员视图回看 7 天,`--user` 回看 30 天。
- `--search` (string): 服务端按作业名称、所有者或账户搜索,最多 128 个 Unicode 字符。
- `--status` (string): 服务端过滤作业状态。
- `--type` (string): 服务端过滤作业类型:`jupyter | webide | custom | pytorch | tensorflow | kuberay | deepspeed | openmpi`。
- `--status` (string slice): 服务端过滤作业状态,可重复或逗号分隔,最多 20 项。
- `--type` (string slice): 服务端过滤作业类型,可重复或逗号分隔,最多 20 项;类型为 `jupyter | webide | custom | pytorch | tensorflow | kuberay | deepspeed | openmpi`。
- `--schedule` (string slice): 服务端过滤调度类型,可重复或逗号分隔,值为 `normal | backfill`,最多 20 项。
- `--node` (string): 服务端过滤运行在指定节点上的作业。
- `--owner` (string): 本地按用户名或作业响应中的 owner 精确筛选。
- `--from` / `--to` (string): 本地按 `createdAt` 时间范围筛选,支持 RFC3339 或 `YYYY-MM-DD`。
Expand Down
Loading