diff --git a/cli/cmd/completion_helpers.go b/cli/cmd/completion_helpers.go index 38fa78d2c..7fd9ba1bb 100644 --- a/cli/cmd/completion_helpers.go +++ b/cli/cmd/completion_helpers.go @@ -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 + } +} diff --git a/cli/cmd/completion_helpers_test.go b/cli/cmd/completion_helpers_test.go new file mode 100644 index 000000000..bd7a499aa --- /dev/null +++ b/cli/cmd/completion_helpers_test.go @@ -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) + } +} diff --git a/cli/cmd/job.go b/cli/cmd/job.go index 6ab7306ea..6a56987da 100644 --- a/cli/cmd/job.go +++ b/cli/cmd/job.go @@ -115,8 +115,9 @@ 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") @@ -124,18 +125,24 @@ func readJobListOptions(cmd *cobra.Command, admin bool) (api.JobListOptions, err 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 } @@ -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") @@ -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) { @@ -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"))) @@ -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"))) @@ -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 == "" { @@ -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") @@ -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)) diff --git a/cli/cmd/job_test.go b/cli/cmd/job_test.go index 5c7d70e9c..ae344c5d4 100644 --- a/cli/cmd/job_test.go +++ b/cli/cmd/job_test.go @@ -2,6 +2,7 @@ package cmd import ( "os" + "slices" "strings" "testing" @@ -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", @@ -110,7 +113,89 @@ 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 TestReadListFlagValuesSupportsSingleRepeatedAndCommaSeparatedValues(t *testing.T) { + for name, test := range map[string]struct { + args []string + want []string + }{ + "single": { + args: []string{"--status", "Running"}, + want: []string{"Running"}, + }, + "repeated and comma-separated": { + args: []string{"--status", "Running", "--status", "Pending,Failed"}, + want: []string{"Running", "Pending", "Failed"}, + }, + } { + t.Run(name, func(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().StringSlice("status", nil, "") + if err := cmd.Flags().Parse(test.args); err != nil { + t.Fatal(err) + } + got := normalizeListFlagValues(readListFlagValues(cmd, "status")) + if !slices.Equal(got, test.want) { + t.Fatalf("status values = %#v, want %#v", got, test.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) + } } } diff --git a/cli/docs/COMMANDS.md b/cli/docs/COMMANDS.md index 360455d0b..6f8d40a6b 100644 --- a/cli/docs/COMMANDS.md +++ b/cli/docs/COMMANDS.md @@ -437,8 +437,9 @@ CLI 发出的平台请求带 `User-Agent: crater-cli/` 与 `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`。 diff --git a/cli/internal/api/job.go b/cli/internal/api/job.go index 5e8d3bc56..d8ad3188f 100644 --- a/cli/internal/api/job.go +++ b/cli/internal/api/job.go @@ -36,16 +36,17 @@ type JobClient interface { type JobListOptions struct { ListOptions - All bool - Admin bool - Username string - Search string - Days int - Status string - JobType string - Node string - Interactive bool - Batch bool + All bool + Admin bool + Username string + Days int + Search string + Statuses []string + JobTypes []string + ScheduleTypes []int + Node string + Interactive bool + Batch bool } type UserInfo struct { @@ -253,8 +254,8 @@ func jobListValues(options JobListOptions) url.Values { if options.Search != "" { values.Set("search", options.Search) } - if options.Status != "" { - values.Set("status", options.Status) + for _, status := range options.Statuses { + values.Add("status", status) } if options.Node != "" { values.Set("node", options.Node) @@ -263,12 +264,15 @@ func jobListValues(options JobListOptions) url.Values { for _, jobType := range types { values.Add("job_type", jobType) } + for _, scheduleType := range options.ScheduleTypes { + values.Add("schedule_type", strconv.Itoa(scheduleType)) + } return values } func selectedJobTypes(options JobListOptions) []string { - if options.JobType != "" { - return []string{options.JobType} + if len(options.JobTypes) > 0 { + return options.JobTypes } if options.Interactive { return []string{"jupyter", "webide"} diff --git a/cli/internal/api/job_test.go b/cli/internal/api/job_test.go index 6955ce518..ca0a8aac9 100644 --- a/cli/internal/api/job_test.go +++ b/cli/internal/api/job_test.go @@ -205,13 +205,18 @@ func TestListJobsSendsPagingAndServerFilters(t *testing.T) { if query.Get("page") != "2" || query.Get("page_size") != "25" || query.Get("sort") != "-createdAt" { t.Fatalf("unexpected paging query: %v", query) } - if query.Get("days") != "14" || query.Get("status") != "Running" || - query.Get("node") != "gpu-01" || query.Get("search") != "trainer" { + if query.Get("days") != "14" || query.Get("search") != "demo" || query.Get("node") != "gpu-01" { t.Fatalf("unexpected filters: %v", query) } - if !reflect.DeepEqual(query["job_type"], []string{"jupyter", "webide"}) { + if !reflect.DeepEqual(query["status"], []string{"Running", "Pending"}) { + t.Fatalf("unexpected statuses: %v", query["status"]) + } + if !reflect.DeepEqual(query["job_type"], []string{"jupyter", "pytorch"}) { t.Fatalf("unexpected job types: %v", query["job_type"]) } + if !reflect.DeepEqual(query["schedule_type"], []string{"1", "0"}) { + t.Fatalf("unexpected schedule types: %v", query["schedule_type"]) + } writer.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(writer).Encode(Response[Page[JobInfo]]{ Data: Page[JobInfo]{Items: []JobInfo{{Name: "job"}}, Total: 1, Page: 2, PageSize: 25}, @@ -219,13 +224,14 @@ func TestListJobsSendsPagingAndServerFilters(t *testing.T) { }) page, err := client.ListJobs(JobListOptions{ - ListOptions: ListOptions{Page: 2, PageSize: 25, Sort: "-createdAt"}, - All: true, - Days: 14, - Status: "Running", - Node: "gpu-01", - Search: "trainer", - Interactive: true, + ListOptions: ListOptions{Page: 2, PageSize: 25, Sort: "-createdAt"}, + All: true, + Days: 14, + Search: "demo", + Statuses: []string{"Running", "Pending"}, + JobTypes: []string{"jupyter", "pytorch"}, + ScheduleTypes: []int{1, 0}, + Node: "gpu-01", }) if err != nil { t.Fatalf("ListJobs returned error: %v", err) diff --git a/cli/internal/i18n/catalog_job.go b/cli/internal/i18n/catalog_job.go index b37418a04..b609f81b4 100644 --- a/cli/internal/i18n/catalog_job.go +++ b/cli/internal/i18n/catalog_job.go @@ -43,6 +43,10 @@ var catalogJob = map[Language]map[string]string{ "job_create_webide_flag_cpu": "CPU request", "job_create_webide_flag_gpu": "GPU count", "job_create_webide_flag_name": "Job display name", + "job_ls_flag_schedule": "Filter by schedule type, repeatable or comma-separated", + "job_ls_flag_search": "Search job names and metadata", + "job_ls_flag_status": "Filter by status, repeatable or comma-separated", + "job_ls_flag_type": "Filter by type, repeatable or comma-separated", "job_logs_flag_all-containers": "Show logs from every container", "job_logs_flag_all-pods": "Show logs from every pod in the job", "job_logs_flag_container": "Select a container by name", @@ -132,6 +136,8 @@ var catalogJob = map[Language]map[string]string{ "err_job_sort_unsupported": "unsupported sort field %q", "err_invalid_job_schedule": "invalid schedule type %q: use normal or backfill", "err_invalid_job_schedule_value": "invalid scheduleType %d: use 0 (backfill) or 1 (normal)", + "err_job_filter_empty": "%s filter values must not be empty", + "err_job_filter_too_many": "%s accepts at most 20 values", "err_invalid_mount_path": "invalid mount path %q: use an absolute non-root path without '..' or '//'", "err_invalid_non_negative_float": "%s must not be negative", "err_invalid_non_negative_int": "%s must not be negative", @@ -216,6 +222,10 @@ var catalogJob = map[Language]map[string]string{ "job_create_webide_flag_cpu": "CPU 请求量", "job_create_webide_flag_gpu": "GPU 数量", "job_create_webide_flag_name": "作业显示名称", + "job_ls_flag_schedule": "按调度类型筛选,可重复或使用逗号分隔", + "job_ls_flag_search": "搜索作业名称和元数据", + "job_ls_flag_status": "按状态筛选,可重复或使用逗号分隔", + "job_ls_flag_type": "按类型筛选,可重复或使用逗号分隔", "job_logs_flag_all-containers": "显示所有容器的日志", "job_logs_flag_all-pods": "显示作业中所有 Pod 的日志", "job_logs_flag_container": "按名称选择容器", @@ -305,6 +315,8 @@ var catalogJob = map[Language]map[string]string{ "err_job_sort_unsupported": "不支持的排序字段 %q", "err_invalid_job_schedule": "无效的调度类型 %q:请使用 normal 或 backfill", "err_invalid_job_schedule_value": "无效的 scheduleType %d:请使用 0(backfill)或 1(normal)", + "err_job_filter_empty": "%s 筛选值不能为空", + "err_job_filter_too_many": "%s 最多接受 20 个值", "err_invalid_mount_path": "无效的挂载路径 %q:请使用不含 '..' 或 '//' 的非根绝对路径", "err_invalid_non_negative_float": "%s 不能为负数", "err_invalid_non_negative_int": "%s 不能为负数", diff --git a/cli/skills/crater-cli-job/SKILL.md b/cli/skills/crater-cli-job/SKILL.md index 978e68e08..dc7c2c49e 100644 --- a/cli/skills/crater-cli-job/SKILL.md +++ b/cli/skills/crater-cli-job/SKILL.md @@ -1,6 +1,6 @@ --- name: crater-cli-job -version: 0.3.0 +version: 0.3.1 description: "Use Crater CLI job commands to list, inspect, view logs, create, stop, and snapshot jobs." metadata: requires: @@ -43,7 +43,14 @@ For Jupyter/WebIDE access commands, the returned token or password is sensitive. List running GPU jobs for a user: ```bash -crater job ls --user alice --status Running --page-size 15 --json --no-interactive +crater job ls \ + --user alice \ + --search experiment \ + --status Running,Pending \ + --type pytorch,tensorflow \ + --schedule normal \ + --all-pages \ + --json --no-interactive ``` Inspect a job: diff --git a/cli/skills/crater-cli-read/SKILL.md b/cli/skills/crater-cli-read/SKILL.md index 618df0858..4224467a5 100644 --- a/cli/skills/crater-cli-read/SKILL.md +++ b/cli/skills/crater-cli-read/SKILL.md @@ -1,6 +1,6 @@ --- name: crater-cli-read -version: 1.3.0 +version: 1.3.1 description: "Crater CLI 用户视图读取域:指导 AI Agent 通过 crater node、job、image、account、resource、dataset、model-download、pod 等用户可见命令查看平台只读信息。管理员视图请使用 crater-cli-admin-read。" metadata: requires: @@ -42,7 +42,7 @@ crater node pods gpu-node-01 --namespace team-workloads --type batch.volcano.sh/ crater node pods gpu-node-01 --all-namespaces --all-pages --json crater node gpu gpu-node-01 --json crater job ls --search experiment --page-size 15 --json -crater job ls --all --days 7 --status Running --json +crater job ls --all --days 7 --search experiment --status Running,Pending --type pytorch --schedule normal --all-pages --json crater job ls --interactive --json crater job get my-job-name --json crater job pods my-job-name --status Running --page-size 15 --json diff --git a/cli/test/snapshots/job/job_test.go b/cli/test/snapshots/job/job_test.go index 3814222d4..ede5b3d70 100644 --- a/cli/test/snapshots/job/job_test.go +++ b/cli/test/snapshots/job/job_test.go @@ -56,13 +56,20 @@ func jobSuccessCases() []snaptest.Case { } } +func jobFilterCases() []snaptest.Case { + return []snaptest.Case{ + {ID: "22-ls-invalid-multi-filters-json", Args: []string{"job", "ls", "--status", "Running,bad", "--type", "custom,nope", "--schedule", "normal,0", "--no-interactive", "--json"}}, + {ID: "23-ls-valid-multi-filters-timeout-json", Args: []string{"job", "ls", "--search", "demo", "--status", "Running,Pending", "--type", "pytorch,tensorflow", "--schedule", "normal,backfill", "--no-interactive", "--json"}}, + } +} + func jobLogCases() []snaptest.Case { return []snaptest.Case{ - {ID: "22-logs-missing-name-nojson", Args: []string{"job", "logs", "--no-interactive"}}, - {ID: "23-logs-negative-tail-json", Args: []string{"job", "logs", "job-123", "--tail", "-1", "--no-interactive", "--json"}}, - {ID: "24-logs-pod-all-pods-conflict-json", Args: []string{"job", "logs", "job-123", "--pod", "pod-1", "--all-pods", "--no-interactive", "--json"}}, - {ID: "25-logs-follow-json-conflict", Args: []string{"job", "logs", "job-123", "--follow", "--no-interactive", "--json"}}, - {ID: "26-logs-follow-previous-conflict-nojson", Args: []string{"job", "logs", "job-123", "--follow", "--previous", "--no-interactive"}}, + {ID: "24-logs-missing-name-nojson", Args: []string{"job", "logs", "--no-interactive"}}, + {ID: "25-logs-negative-tail-json", Args: []string{"job", "logs", "job-123", "--tail", "-1", "--no-interactive", "--json"}}, + {ID: "26-logs-pod-all-pods-conflict-json", Args: []string{"job", "logs", "job-123", "--pod", "pod-1", "--all-pods", "--no-interactive", "--json"}}, + {ID: "27-logs-follow-json-conflict", Args: []string{"job", "logs", "job-123", "--follow", "--no-interactive", "--json"}}, + {ID: "28-logs-follow-previous-conflict-nojson", Args: []string{"job", "logs", "job-123", "--follow", "--previous", "--no-interactive"}}, } } @@ -142,6 +149,11 @@ func runJobSnapshots(t *testing.T, lang string) { cases = append(cases, successCases...) results = append(results, successResults...) + filterCases := jobFilterCases() + filterResults := runJobCases(t, bin, timeoutEnv, filterCases) + cases = append(cases, filterCases...) + results = append(results, filterResults...) + logCases := jobLogCases() logResults := runJobCases(t, bin, timeoutEnv, logCases) cases = append(cases, logCases...) diff --git a/cli/testdata/snapshots/job/job.en.txtar b/cli/testdata/snapshots/job/job.en.txtar index 3f94ed24b..a88deeb9f 100644 --- a/cli/testdata/snapshots/job/job.en.txtar +++ b/cli/testdata/snapshots/job/job.en.txtar @@ -371,52 +371,96 @@ crater job ls --page 2 --page-size 2 --json --no-interactive "status": "OK" } -- en/21-ls-page-success-json/stderr -- --- en/22-logs-missing-name-nojson/argv -- +-- en/22-ls-invalid-multi-filters-json/argv -- +crater job ls --status Running,bad --type custom,nope --schedule normal,0 --no-interactive --json +-- en/22-ls-invalid-multi-filters-json/exit -- +2 +-- en/22-ls-invalid-multi-filters-json/stdout -- +-- en/22-ls-invalid-multi-filters-json/stderr -- +{ + "category": "usage_error", + "code": "ERR_INVALID_FLAG_VALUE", + "message": "invalid job status: bad\ninvalid job type: nope\ninvalid schedule type \"0\": use normal or backfill", + "context": { + "issues": [ + { + "code": "ERR_INVALID_FLAG_VALUE", + "field": "status", + "message": "invalid job status: bad" + }, + { + "code": "ERR_INVALID_FLAG_VALUE", + "field": "type", + "message": "invalid job type: nope" + }, + { + "code": "ERR_INVALID_FLAG_VALUE", + "field": "schedule", + "message": "invalid schedule type \"0\": use normal or backfill" + } + ] + } +} +-- en/23-ls-valid-multi-filters-timeout-json/argv -- +crater job ls --search demo --status Running,Pending --type pytorch,tensorflow --schedule normal,backfill --no-interactive --json +-- en/23-ls-valid-multi-filters-timeout-json/exit -- +4 +-- en/23-ls-valid-multi-filters-timeout-json/stdout -- +-- en/23-ls-valid-multi-filters-timeout-json/stderr -- +{ + "category": "api_error", + "code": "ERR_NETWORK_FAILURE", + "message": "could not reach server: Get \"https://example.invalid/api/v1/vcjobs?job_type=pytorch\u0026job_type=tensorflow\u0026page=1\u0026page_size=15\u0026schedule_type=1\u0026schedule_type=0\u0026search=demo\u0026status=Running\u0026status=Pending\": context deadline exceeded", + "context": { + "msg": "network error: Get \"https://example.invalid/api/v1/vcjobs?job_type=pytorch\u0026job_type=tensorflow\u0026page=1\u0026page_size=15\u0026schedule_type=1\u0026schedule_type=0\u0026search=demo\u0026status=Running\u0026status=Pending\": context deadline exceeded" + } +} +-- en/24-logs-missing-name-nojson/argv -- crater job logs --no-interactive --- en/22-logs-missing-name-nojson/exit -- +-- en/24-logs-missing-name-nojson/exit -- 2 --- en/22-logs-missing-name-nojson/stdout -- --- en/22-logs-missing-name-nojson/stderr -- +-- en/24-logs-missing-name-nojson/stdout -- +-- en/24-logs-missing-name-nojson/stderr -- Error: job name is required () --- en/23-logs-negative-tail-json/argv -- +-- en/25-logs-negative-tail-json/argv -- crater job logs job-123 --tail -1 --no-interactive --json --- en/23-logs-negative-tail-json/exit -- +-- en/25-logs-negative-tail-json/exit -- 2 --- en/23-logs-negative-tail-json/stdout -- --- en/23-logs-negative-tail-json/stderr -- +-- en/25-logs-negative-tail-json/stdout -- +-- en/25-logs-negative-tail-json/stderr -- { "category": "usage_error", "code": "ERR_INVALID_FLAG_VALUE", "message": "tail must not be negative" } --- en/24-logs-pod-all-pods-conflict-json/argv -- +-- en/26-logs-pod-all-pods-conflict-json/argv -- crater job logs job-123 --pod pod-1 --all-pods --no-interactive --json --- en/24-logs-pod-all-pods-conflict-json/exit -- +-- en/26-logs-pod-all-pods-conflict-json/exit -- 2 --- en/24-logs-pod-all-pods-conflict-json/stdout -- --- en/24-logs-pod-all-pods-conflict-json/stderr -- +-- en/26-logs-pod-all-pods-conflict-json/stdout -- +-- en/26-logs-pod-all-pods-conflict-json/stderr -- { "category": "usage_error", "code": "ERR_INVALID_FLAG_VALUE", "message": "--pod and --all-pods cannot be used together" } --- en/25-logs-follow-json-conflict/argv -- +-- en/27-logs-follow-json-conflict/argv -- crater job logs job-123 --follow --no-interactive --json --- en/25-logs-follow-json-conflict/exit -- +-- en/27-logs-follow-json-conflict/exit -- 2 --- en/25-logs-follow-json-conflict/stdout -- --- en/25-logs-follow-json-conflict/stderr -- +-- en/27-logs-follow-json-conflict/stdout -- +-- en/27-logs-follow-json-conflict/stderr -- { "category": "usage_error", "code": "ERR_INVALID_FLAG_VALUE", "message": "--follow cannot be used with --json" } --- en/26-logs-follow-previous-conflict-nojson/argv -- +-- en/28-logs-follow-previous-conflict-nojson/argv -- crater job logs job-123 --follow --previous --no-interactive --- en/26-logs-follow-previous-conflict-nojson/exit -- +-- en/28-logs-follow-previous-conflict-nojson/exit -- 2 --- en/26-logs-follow-previous-conflict-nojson/stdout -- --- en/26-logs-follow-previous-conflict-nojson/stderr -- +-- en/28-logs-follow-previous-conflict-nojson/stdout -- +-- en/28-logs-follow-previous-conflict-nojson/stderr -- Error: --follow cannot be used with --previous diff --git a/cli/testdata/snapshots/job/job.zh-CN.txtar b/cli/testdata/snapshots/job/job.zh-CN.txtar index dd56d1b62..8d6a73240 100644 --- a/cli/testdata/snapshots/job/job.zh-CN.txtar +++ b/cli/testdata/snapshots/job/job.zh-CN.txtar @@ -371,52 +371,96 @@ crater job ls --page 2 --page-size 2 --json --no-interactive "status": "OK" } -- zh-CN/21-ls-page-success-json/stderr -- --- zh-CN/22-logs-missing-name-nojson/argv -- +-- zh-CN/22-ls-invalid-multi-filters-json/argv -- +crater job ls --status Running,bad --type custom,nope --schedule normal,0 --no-interactive --json +-- zh-CN/22-ls-invalid-multi-filters-json/exit -- +2 +-- zh-CN/22-ls-invalid-multi-filters-json/stdout -- +-- zh-CN/22-ls-invalid-multi-filters-json/stderr -- +{ + "category": "usage_error", + "code": "ERR_INVALID_FLAG_VALUE", + "message": "无效的作业状态:bad\n无效的作业类型:nope\n无效的调度类型 \"0\":请使用 normal 或 backfill", + "context": { + "issues": [ + { + "code": "ERR_INVALID_FLAG_VALUE", + "field": "status", + "message": "无效的作业状态:bad" + }, + { + "code": "ERR_INVALID_FLAG_VALUE", + "field": "type", + "message": "无效的作业类型:nope" + }, + { + "code": "ERR_INVALID_FLAG_VALUE", + "field": "schedule", + "message": "无效的调度类型 \"0\":请使用 normal 或 backfill" + } + ] + } +} +-- zh-CN/23-ls-valid-multi-filters-timeout-json/argv -- +crater job ls --search demo --status Running,Pending --type pytorch,tensorflow --schedule normal,backfill --no-interactive --json +-- zh-CN/23-ls-valid-multi-filters-timeout-json/exit -- +4 +-- zh-CN/23-ls-valid-multi-filters-timeout-json/stdout -- +-- zh-CN/23-ls-valid-multi-filters-timeout-json/stderr -- +{ + "category": "api_error", + "code": "ERR_NETWORK_FAILURE", + "message": "无法连接服务器:Get \"https://example.invalid/api/v1/vcjobs?job_type=pytorch\u0026job_type=tensorflow\u0026page=1\u0026page_size=15\u0026schedule_type=1\u0026schedule_type=0\u0026search=demo\u0026status=Running\u0026status=Pending\": context deadline exceeded", + "context": { + "msg": "network error: Get \"https://example.invalid/api/v1/vcjobs?job_type=pytorch\u0026job_type=tensorflow\u0026page=1\u0026page_size=15\u0026schedule_type=1\u0026schedule_type=0\u0026search=demo\u0026status=Running\u0026status=Pending\": context deadline exceeded" + } +} +-- zh-CN/24-logs-missing-name-nojson/argv -- crater job logs --no-interactive --- zh-CN/22-logs-missing-name-nojson/exit -- +-- zh-CN/24-logs-missing-name-nojson/exit -- 2 --- zh-CN/22-logs-missing-name-nojson/stdout -- --- zh-CN/22-logs-missing-name-nojson/stderr -- +-- zh-CN/24-logs-missing-name-nojson/stdout -- +-- zh-CN/24-logs-missing-name-nojson/stderr -- Error: 缺少必要参数:作业名称 () --- zh-CN/23-logs-negative-tail-json/argv -- +-- zh-CN/25-logs-negative-tail-json/argv -- crater job logs job-123 --tail -1 --no-interactive --json --- zh-CN/23-logs-negative-tail-json/exit -- +-- zh-CN/25-logs-negative-tail-json/exit -- 2 --- zh-CN/23-logs-negative-tail-json/stdout -- --- zh-CN/23-logs-negative-tail-json/stderr -- +-- zh-CN/25-logs-negative-tail-json/stdout -- +-- zh-CN/25-logs-negative-tail-json/stderr -- { "category": "usage_error", "code": "ERR_INVALID_FLAG_VALUE", "message": "tail 不能为负数" } --- zh-CN/24-logs-pod-all-pods-conflict-json/argv -- +-- zh-CN/26-logs-pod-all-pods-conflict-json/argv -- crater job logs job-123 --pod pod-1 --all-pods --no-interactive --json --- zh-CN/24-logs-pod-all-pods-conflict-json/exit -- +-- zh-CN/26-logs-pod-all-pods-conflict-json/exit -- 2 --- zh-CN/24-logs-pod-all-pods-conflict-json/stdout -- --- zh-CN/24-logs-pod-all-pods-conflict-json/stderr -- +-- zh-CN/26-logs-pod-all-pods-conflict-json/stdout -- +-- zh-CN/26-logs-pod-all-pods-conflict-json/stderr -- { "category": "usage_error", "code": "ERR_INVALID_FLAG_VALUE", "message": "--pod 与 --all-pods 不能同时使用" } --- zh-CN/25-logs-follow-json-conflict/argv -- +-- zh-CN/27-logs-follow-json-conflict/argv -- crater job logs job-123 --follow --no-interactive --json --- zh-CN/25-logs-follow-json-conflict/exit -- +-- zh-CN/27-logs-follow-json-conflict/exit -- 2 --- zh-CN/25-logs-follow-json-conflict/stdout -- --- zh-CN/25-logs-follow-json-conflict/stderr -- +-- zh-CN/27-logs-follow-json-conflict/stdout -- +-- zh-CN/27-logs-follow-json-conflict/stderr -- { "category": "usage_error", "code": "ERR_INVALID_FLAG_VALUE", "message": "--follow 不能与 --json 同时使用" } --- zh-CN/26-logs-follow-previous-conflict-nojson/argv -- +-- zh-CN/28-logs-follow-previous-conflict-nojson/argv -- crater job logs job-123 --follow --previous --no-interactive --- zh-CN/26-logs-follow-previous-conflict-nojson/exit -- +-- zh-CN/28-logs-follow-previous-conflict-nojson/exit -- 2 --- zh-CN/26-logs-follow-previous-conflict-nojson/stdout -- --- zh-CN/26-logs-follow-previous-conflict-nojson/stderr -- +-- zh-CN/28-logs-follow-previous-conflict-nojson/stdout -- +-- zh-CN/28-logs-follow-previous-conflict-nojson/stderr -- Error: --follow 不能与 --previous 同时使用