feat(job-array-one-based-index): xcecli list and control use the selection parser (task 8.2)
ID lists split only at commas outside brackets and every selection is validated before any request (control allows identity ranges, listing refuses them). A raw -J value with brackets is checked before the wildcard mapping, and the search rule and * stripping apply to the name part only (-J 'foo*[1]' lists element 1 of arrays whose name starts with foo); values without brackets map exactly as before. Control results print each item's own job ID when the server sends it, so a pre-upgrade element 0 shows as <array-id>[0]. Control and list help show <array-id>[k] and <array-id>[a-b]; list help now says -J. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bc616e8c4a
commit
58ca7abb36
7 changed files with 439 additions and 38 deletions
|
|
@ -45,13 +45,16 @@ func (c *JobController) RunJobsControl() error {
|
|||
}
|
||||
}
|
||||
|
||||
// control jobs by job IDs
|
||||
// control jobs by job IDs. Every selection is validated before any request
|
||||
// is sent (a rejected selection rejects the whole command); control commands
|
||||
// accept element ranges ("<array-id>[a-b]").
|
||||
func (c *JobController) controlJobsByIDs(apiClient *request.APIClient, token string) error {
|
||||
apiIDs := make([]string, 0, len(c.jobIDs))
|
||||
for _, id := range c.jobIDs {
|
||||
apiID, err := format.ToAPIJobID(strings.TrimSpace(id), true)
|
||||
apiID, err := format.ToAPIJobID(id, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid job ID %q: %w", id, err)
|
||||
// the selection error already quotes the trimmed selection
|
||||
return err
|
||||
}
|
||||
apiIDs = append(apiIDs, apiID)
|
||||
}
|
||||
|
|
@ -66,6 +69,13 @@ func (c *JobController) controlJobsByIDs(apiClient *request.APIClient, token str
|
|||
|
||||
// control jobs by filter conditions
|
||||
func (c *JobController) controlJobsByFilter(apiClient *request.APIClient, token string) error {
|
||||
// A -J value with '[' or ']' is an element selection by name: check it
|
||||
// before sending. The value itself is sent unchanged (eq, as today).
|
||||
if c.filter.JobName != "" {
|
||||
if _, err := format.ParseNameSelection(c.filter.JobName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
url := "/xce/v1/jobs/control"
|
||||
|
||||
req := models.JobControlRequest{
|
||||
|
|
@ -94,15 +104,25 @@ func (c *JobController) printJobControlResponse(req interface{}, apiClient *requ
|
|||
return err
|
||||
}
|
||||
for _, data := range resp.Data.Accepted {
|
||||
fmt.Printf("job <%s> %s\n", format.FormatArrayChildID(data.JobID, data.ArrayIdx), strings.TrimPrefix(data.Msg, "job "))
|
||||
fmt.Printf("job <%s> %s\n", controlResultID(data), strings.TrimPrefix(data.Msg, "job "))
|
||||
}
|
||||
|
||||
for _, data := range resp.Data.Failed {
|
||||
fmt.Printf("job <%s> %s\n", format.FormatArrayChildID(data.JobID, data.ArrayIdx), strings.TrimPrefix(data.Msg, "job "))
|
||||
fmt.Printf("job <%s> %s\n", controlResultID(data), strings.TrimPrefix(data.Msg, "job "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// controlResultID renders a control result's job: from its own job ID when
|
||||
// the server sends one (so a pre-upgrade element 0 prints as <array-id>[0]),
|
||||
// otherwise from job_id + array_idx as before (older servers).
|
||||
func controlResultID(item *models.JobControlResponseItem) string {
|
||||
if item.UniqueJobID != 0 {
|
||||
return format.FormatJobID(item.UniqueJobID)
|
||||
}
|
||||
return format.FormatArrayChildID(item.JobID, item.ArrayIdx)
|
||||
}
|
||||
|
||||
// Parse parse command line arguments
|
||||
// Parse command line arguments
|
||||
func (c *JobController) ParseCmdArgs(cmd *cobra.Command, args []string) error {
|
||||
|
|
@ -110,8 +130,8 @@ func (c *JobController) ParseCmdArgs(cmd *cobra.Command, args []string) error {
|
|||
if len(args) == 1 && args[0] == "0" {
|
||||
c.filter.AllJobs = true
|
||||
} else {
|
||||
// parse job ID parameters
|
||||
c.jobIDs = strings.Split(args[0], ",")
|
||||
// parse job ID parameters: split only at commas outside [...]
|
||||
c.jobIDs = format.SplitSelections(args[0])
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -133,7 +153,10 @@ func NewKillJobsCmd(configManager *config.ConfigManager) *cobra.Command {
|
|||
Long: `Kill jobs by job IDs or filters.
|
||||
Examples:
|
||||
xce jobs kill jobid1,jobid2,... # kill jobs by job IDs
|
||||
xce jobs kill <array-id>[k] # kill element k of an array
|
||||
xce jobs kill <array-id>[a-b] # kill elements a through b of an array
|
||||
xce jobs kill -J jobname # kill jobs by job name
|
||||
xce jobs kill -J 'jobname[k]' # kill element k of every array named jobname
|
||||
xce jobs kill -q queue_name # kill jobs in a specific queue
|
||||
xce jobs kill -u username # kill jobs by user`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
|
|
@ -169,7 +192,10 @@ func NewStopJobsCmd(configManager *config.ConfigManager) *cobra.Command {
|
|||
Long: `Stop jobs by job IDs or filters.
|
||||
Examples:
|
||||
xce jobs stop jobid1,jobid2,... # stop jobs by job IDs
|
||||
xce jobs stop <array-id>[k] # stop element k of an array
|
||||
xce jobs stop <array-id>[a-b] # stop elements a through b of an array
|
||||
xce jobs stop -J jobname # stop jobs by job name
|
||||
xce jobs stop -J 'jobname[k]' # stop element k of every array named jobname
|
||||
xce jobs stop -q queue_name # stop jobs in a specific queue
|
||||
xce jobs stop -u username # stop jobs by user`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
|
|
@ -205,7 +231,10 @@ func NewResumeJobsCmd(configManager *config.ConfigManager) *cobra.Command {
|
|||
Long: `Resume jobs by job IDs or filters.
|
||||
Examples:
|
||||
xce jobs resume jobid1,jobid2,... # resume jobs by job IDs
|
||||
xce jobs resume <array-id>[k] # resume element k of an array
|
||||
xce jobs resume <array-id>[a-b] # resume elements a through b of an array
|
||||
xce jobs resume -J jobname # resume jobs by job name
|
||||
xce jobs resume -J 'jobname[k]' # resume element k of every array named jobname
|
||||
xce jobs resume -u username # resume jobs by user`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := controller.ParseCmdArgs(cmd, args); err != nil {
|
||||
|
|
@ -237,7 +266,9 @@ func NewCheckPointJobsCmd(configManager *config.ConfigManager) *cobra.Command {
|
|||
Short: "Checkpoint jobs",
|
||||
Long: `Checkpoint jobs by job IDs or filters.
|
||||
Examples:
|
||||
xce jobs checkpoint jobid1,jobid2,... # checkpoint jobs by job IDs`,
|
||||
xce jobs checkpoint jobid1,jobid2,... # checkpoint jobs by job IDs
|
||||
xce jobs checkpoint <array-id>[k] # checkpoint element k of an array
|
||||
xce jobs checkpoint <array-id>[a-b] # checkpoint elements a through b of an array`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := controller.ParseCmdArgs(cmd, args); err != nil {
|
||||
fmt.Println(strings.ToLower(err.Error()))
|
||||
|
|
|
|||
234
src/frontend/xcecli/cmd/jobs/controller_test.go
Normal file
234
src/frontend/xcecli/cmd/jobs/controller_test.go
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
package jobs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"xcecli/common/format"
|
||||
"xcecli/common/request"
|
||||
"xcecli/models"
|
||||
)
|
||||
|
||||
// Array-capable identity used by the tests below: tag 0, sequence 32768, so
|
||||
// the low 15 (element) bits are clear (5.8).
|
||||
const (
|
||||
testArrayWritten = "0-0-1700000000-32768"
|
||||
)
|
||||
|
||||
var testArrayKey = format.MakeJobID(0, 0, 1700000000, 0, 32768)
|
||||
|
||||
// controlServer is an httptest server that records every request path and
|
||||
// answers with the given control result.
|
||||
type controlServer struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
paths []string
|
||||
}
|
||||
|
||||
func newControlServer(t *testing.T, data models.JobControlResponseData) *controlServer {
|
||||
t.Helper()
|
||||
cs := &controlServer{}
|
||||
cs.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cs.mu.Lock()
|
||||
cs.paths = append(cs.paths, r.URL.Path)
|
||||
cs.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(models.JobControlResponse{Code: 0, Msg: "ok", Data: &data})
|
||||
}))
|
||||
t.Cleanup(cs.srv.Close)
|
||||
return cs
|
||||
}
|
||||
|
||||
func (cs *controlServer) requests() []string {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
return append([]string(nil), cs.paths...)
|
||||
}
|
||||
|
||||
// captureStdout runs fn and returns what it printed to os.Stdout.
|
||||
func captureStdout(t *testing.T, fn func() error) (string, error) {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
old := os.Stdout
|
||||
os.Stdout = w
|
||||
done := make(chan string)
|
||||
go func() {
|
||||
var buf bytes.Buffer
|
||||
_, _ = io.Copy(&buf, r)
|
||||
done <- buf.String()
|
||||
}()
|
||||
fnErr := fn()
|
||||
os.Stdout = old
|
||||
_ = w.Close()
|
||||
return <-done, fnErr
|
||||
}
|
||||
|
||||
func newTestController(args string) (*JobController, error) {
|
||||
c := NewJobController(nil, "kill")
|
||||
return c, c.ParseCmdArgs(nil, []string{args})
|
||||
}
|
||||
|
||||
// X[1-3] reaches the request path as <decimal>[1-3] (control allows ranges).
|
||||
func TestControlJobsByIDs_RangeReachesPathAsDecimal(t *testing.T) {
|
||||
cs := newControlServer(t, models.JobControlResponseData{})
|
||||
c, err := newTestController(testArrayWritten + "[1-3]")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCmdArgs: %v", err)
|
||||
}
|
||||
if _, err := captureStdout(t, func() error {
|
||||
return c.controlJobsByIDs(request.NewAPIClient(cs.srv.URL), "tok")
|
||||
}); err != nil {
|
||||
t.Fatalf("controlJobsByIDs: %v", err)
|
||||
}
|
||||
got := cs.requests()
|
||||
want := "/xce/v1/jobs/" + strconv.FormatUint(testArrayKey, 10) + "[1-3]/control"
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("request paths = %v, want [%s]", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Two selections, one with a comma inside the brackets: split only outside
|
||||
// brackets, and the rejected selection is reported as written, before any
|
||||
// request.
|
||||
func TestControlJobsByIDs_SplitsOutsideBracketsAndValidatesFirst(t *testing.T) {
|
||||
cases := []struct {
|
||||
args string
|
||||
wantSub string
|
||||
}{
|
||||
{testArrayWritten + "[1,3]," + testArrayWritten + "[2]", "'" + testArrayWritten + "[1,3]'"},
|
||||
{testArrayWritten + "[1], " + testArrayWritten + "[40000]", "'" + testArrayWritten + "[40000]'"},
|
||||
{testArrayWritten + "[2],0-0-1700000000-5[1]", "'0-0-1700000000-5[1]'"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.args, func(t *testing.T) {
|
||||
cs := newControlServer(t, models.JobControlResponseData{})
|
||||
c, err := newTestController(tc.args)
|
||||
if err == nil {
|
||||
_, err = captureStdout(t, func() error {
|
||||
return c.controlJobsByIDs(request.NewAPIClient(cs.srv.URL), "tok")
|
||||
})
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection for %q", tc.args)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantSub) {
|
||||
t.Errorf("error %q does not quote %s", err.Error(), tc.wantSub)
|
||||
}
|
||||
if n := len(cs.requests()); n != 0 {
|
||||
t.Errorf("%d request(s) sent, want none", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -J with a malformed element part is refused before any request; a valid
|
||||
// bracketed -J is sent unchanged (eq, as today).
|
||||
func TestControlJobsByFilter_NameSelectionCheckedFirst(t *testing.T) {
|
||||
for _, bad := range []string{"foo[1]*", "foo [1]", "foo[40000]", "foo[3-1]", "foo[1"} {
|
||||
t.Run(bad, func(t *testing.T) {
|
||||
cs := newControlServer(t, models.JobControlResponseData{})
|
||||
c := NewJobController(nil, "kill")
|
||||
c.filter.JobName = bad
|
||||
_, err := captureStdout(t, func() error {
|
||||
return c.controlJobsByFilter(request.NewAPIClient(cs.srv.URL), "tok")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection for -J %q", bad)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "'"+strings.TrimSpace(bad)+"'") {
|
||||
t.Errorf("error %q does not quote the selection", err.Error())
|
||||
}
|
||||
if n := len(cs.requests()); n != 0 {
|
||||
t.Errorf("%d request(s) sent, want none", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("valid", func(t *testing.T) {
|
||||
cs := newControlServer(t, models.JobControlResponseData{})
|
||||
c := NewJobController(nil, "kill")
|
||||
c.filter.JobName = "foo[1-2]"
|
||||
if _, err := captureStdout(t, func() error {
|
||||
return c.controlJobsByFilter(request.NewAPIClient(cs.srv.URL), "tok")
|
||||
}); err != nil {
|
||||
t.Fatalf("controlJobsByFilter: %v", err)
|
||||
}
|
||||
if n := len(cs.requests()); n != 1 {
|
||||
t.Fatalf("%d request(s) sent, want 1", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A pre-upgrade element 0 result prints as <array-id>[0] (8.3); without
|
||||
// unique_job_id (older server) the result falls back to job_id + array_idx.
|
||||
func TestControlResultPrinting(t *testing.T) {
|
||||
elem0 := int64(testArrayKey | (uint64(1) << 62))
|
||||
data := models.JobControlResponseData{
|
||||
Accepted: []*models.JobControlResponseItem{
|
||||
{JobID: int64(testArrayKey), ArrayIdx: 0, UniqueJobID: elem0, Msg: "job killed"},
|
||||
{JobID: int64(testArrayKey), ArrayIdx: 2, Msg: "job killed"},
|
||||
},
|
||||
Failed: []*models.JobControlResponseItem{
|
||||
{JobID: int64(testArrayKey), ArrayIdx: 3, UniqueJobID: elem0 | 3, Msg: "job not found"},
|
||||
},
|
||||
}
|
||||
cs := newControlServer(t, data)
|
||||
c, err := newTestController(testArrayWritten + "[0]")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCmdArgs: %v", err)
|
||||
}
|
||||
out, err := captureStdout(t, func() error {
|
||||
return c.controlJobsByIDs(request.NewAPIClient(cs.srv.URL), "tok")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("controlJobsByIDs: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"job <" + testArrayWritten + "[0]> killed",
|
||||
"job <" + testArrayWritten + "[2]> killed",
|
||||
"job <" + testArrayWritten + "[3]> not found",
|
||||
}
|
||||
got := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("output:\n%s\nwant:\n%s", out, strings.Join(want, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// Control help shows how to name one element and a range of elements (9.2).
|
||||
func TestControlHelpShowsElementForms(t *testing.T) {
|
||||
for name, cmd := range map[string]string{
|
||||
"kill": NewKillJobsCmd(nil).Long,
|
||||
"stop": NewStopJobsCmd(nil).Long,
|
||||
"resume": NewResumeJobsCmd(nil).Long,
|
||||
"checkpoint": NewCheckPointJobsCmd(nil).Long,
|
||||
} {
|
||||
for _, form := range []string{"<array-id>[k]", "<array-id>[a-b]"} {
|
||||
if !strings.Contains(cmd, form) {
|
||||
t.Errorf("%s help lacks %s", name, form)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List help names the -J flag (not -j) and shows an element selection.
|
||||
func TestListHelpShowsElementForms(t *testing.T) {
|
||||
long := NewListJobsCmd(nil).Long
|
||||
if strings.Contains(long, "list -j ") {
|
||||
t.Errorf("list help still documents -j for job name")
|
||||
}
|
||||
for _, s := range []string{"-J jobname", "<array-id>[k]", "-J 'name[a-b]'"} {
|
||||
if !strings.Contains(long, s) {
|
||||
t.Errorf("list help lacks %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -294,6 +294,7 @@ func NewListJobsCmd(configManager *config.ConfigManager) *cobra.Command {
|
|||
Example:
|
||||
xce jobs list # list all jobs
|
||||
xce jobs list jobid0,jobid1,...jobidn # list jobs by job id
|
||||
xce jobs list <array-id>[k] # list element k of an array
|
||||
xce jobs list -a # list run,done,pending,suspended jobs
|
||||
xce jobs list -d # list done jobs
|
||||
xce jobs list -d -r # list done and running jobs
|
||||
|
|
@ -302,7 +303,9 @@ Example:
|
|||
xce jobs list -d -u all # list done jobs by all users
|
||||
xce jobs list -d -r -s -p -u username # list done and running and suspended and pending jobs by user
|
||||
xce jobs list -d -r -s -p -u username -q queuename # list done and running and suspended and pending jobs by user and queue
|
||||
xce jobs list -j jobname # list jobs by job name
|
||||
xce jobs list -J jobname # list jobs by job name
|
||||
xce jobs list -J 'name[k]' # list element k of every array named name
|
||||
xce jobs list -J 'name[a-b]' # list elements a through b of every array named name
|
||||
xce jobs list -Jd jobdescription # list jobs by job description
|
||||
xce jobs list --Lp licenseprojectname # list license project jobs
|
||||
xce jobs list -o "jobid submit_user ..." # list jobs by fields
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package filters
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"xcecli/common/format"
|
||||
|
|
@ -40,13 +39,12 @@ func (f *JobsFilter) HandleJobFilter(array_type string) (string, error) {
|
|||
var filters []string
|
||||
|
||||
if f.JobIDs != "" {
|
||||
jobIdStr := strings.Split(f.JobIDs, ",")
|
||||
// split only at commas outside [...]; every selection is checked
|
||||
// before the filter is built (queries refuse identity ranges)
|
||||
jobIdStr := format.SplitSelections(f.JobIDs)
|
||||
apiIDs := make([]string, 0, len(jobIdStr))
|
||||
for _, jobId := range jobIdStr {
|
||||
if err := isValidJobId(jobId); err != nil {
|
||||
return "", err
|
||||
}
|
||||
apiID, err := format.ToAPIJobID(strings.TrimSpace(jobId), false)
|
||||
apiID, err := format.ToAPIJobID(jobId, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -79,15 +77,27 @@ func (f *JobsFilter) HandleJobFilter(array_type string) (string, error) {
|
|||
}
|
||||
|
||||
if f.JobName != "" {
|
||||
// A value with '[' or ']' is an element selection by name: check the
|
||||
// raw text before the wildcard mapping, so '*' can only be in the
|
||||
// name part.
|
||||
sel, err := format.ParseNameSelection(f.JobName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Check for unsupported wildcard characters
|
||||
if strings.Contains(f.JobName, "%") || strings.Contains(f.JobName, "?") {
|
||||
return "", fmt.Errorf("unsupported wildcard characters (%% or ?) in job name: %s. Only * is supported", f.JobName)
|
||||
}
|
||||
|
||||
rule := generateSearchRule(f.JobName)
|
||||
// The search rule and '*' removal apply to the name part only; for a
|
||||
// value without brackets that is the whole value, as before.
|
||||
namePart, elemPart := f.JobName, ""
|
||||
if sel.Form != format.SelBare {
|
||||
namePart, elemPart = sel.Name, sel.Raw[len(sel.Name):]
|
||||
}
|
||||
rule := generateSearchRule(namePart)
|
||||
// Remove wildcards from the actual search value
|
||||
searchValue := f.JobName
|
||||
searchValue = strings.ReplaceAll(searchValue, "*", "")
|
||||
searchValue := strings.ReplaceAll(namePart, "*", "") + elemPart
|
||||
filters = append(filters, fmt.Sprintf("{job_name:%s:%s}", rule, searchValue))
|
||||
}
|
||||
|
||||
|
|
@ -171,22 +181,6 @@ func GetJobStatusSet(f *JobsFilter) []string {
|
|||
return result
|
||||
}
|
||||
|
||||
var (
|
||||
reFmtJobID = regexp.MustCompile(`^\d+-\d+-\d+-\d+$`)
|
||||
reFmtJobArrayID = regexp.MustCompile(`^\d+-\d+-\d+-\d+\[\d+\]$`)
|
||||
reNumericID = regexp.MustCompile(`^\d+$`)
|
||||
reNumericIndex = regexp.MustCompile(`^\d+\[\d+\]$`)
|
||||
)
|
||||
|
||||
func isValidJobId(jobID string) error {
|
||||
s := strings.TrimSpace(jobID)
|
||||
if reFmtJobID.MatchString(s) || reFmtJobArrayID.MatchString(s) ||
|
||||
reNumericID.MatchString(s) || reNumericIndex.MatchString(s) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("jobid '%s': must be numeric, numeric[idx], or cluster-apiserver-timestamp-seq[idx] format", jobID)
|
||||
}
|
||||
|
||||
// Helper function, generates search rule
|
||||
func generateSearchRule(rule string) string {
|
||||
if rule == "*" {
|
||||
|
|
|
|||
138
src/frontend/xcecli/filters/job_filter_test.go
Normal file
138
src/frontend/xcecli/filters/job_filter_test.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package filters
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"xcecli/common/format"
|
||||
)
|
||||
|
||||
const testArrayWritten = "0-0-1700000000-32768"
|
||||
|
||||
var testArrayDecimal = strconv.FormatUint(format.MakeJobID(0, 0, 1700000000, 0, 32768), 10)
|
||||
|
||||
// ID lists split only at commas outside brackets; every selection is checked
|
||||
// before a filter is built; listing refuses identity ranges.
|
||||
func TestHandleJobFilter_JobIDs(t *testing.T) {
|
||||
ok := []struct {
|
||||
ids string
|
||||
want string
|
||||
}{
|
||||
{testArrayWritten + "[1]," + testArrayWritten + "[2]",
|
||||
"{job_id:in:(" + testArrayDecimal + "[1]," + testArrayDecimal + "[2])}"},
|
||||
{" 123456 , " + testArrayWritten + "[0]",
|
||||
"{job_id:in:(123456," + testArrayDecimal + "[0])}"},
|
||||
{testArrayDecimal + "[3]", "{job_id:in:(" + testArrayDecimal + "[3])}"},
|
||||
}
|
||||
for _, tc := range ok {
|
||||
t.Run(tc.ids, func(t *testing.T) {
|
||||
f := &JobsFilter{JobIDs: tc.ids}
|
||||
got, err := f.HandleJobFilter("")
|
||||
if err != nil {
|
||||
t.Fatalf("HandleJobFilter: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("filter = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
bad := []struct {
|
||||
ids string
|
||||
quoted string
|
||||
wantRule string
|
||||
}{
|
||||
{testArrayWritten + "[1,3]," + testArrayWritten + "[2]", testArrayWritten + "[1,3]", "5.16"},
|
||||
{testArrayWritten + "[1-3]", testArrayWritten + "[1-3]", format.RuleQueryRange},
|
||||
{"100, " + testArrayWritten + "[40000]", testArrayWritten + "[40000]", "5.7"},
|
||||
{"0-0-1700000000-5[1]", "0-0-1700000000-5[1]", "5.8"},
|
||||
{testArrayWritten + " [1]", testArrayWritten + " [1]", "5.16"},
|
||||
}
|
||||
for _, tc := range bad {
|
||||
t.Run("reject "+tc.ids, func(t *testing.T) {
|
||||
f := &JobsFilter{JobIDs: tc.ids}
|
||||
_, err := f.HandleJobFilter("")
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "'"+tc.quoted+"'") {
|
||||
t.Errorf("error %q does not quote %q", err.Error(), tc.quoted)
|
||||
}
|
||||
se, isSel := err.(*format.SelError)
|
||||
if !isSel || se.Rule != tc.wantRule {
|
||||
t.Errorf("error %#v, want *SelError rule %s", err, tc.wantRule)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -J: a bracketed value is checked raw first, then the search rule and '*'
|
||||
// stripping come from the name part only; plain values keep today's mapping.
|
||||
func TestHandleJobFilter_JobName(t *testing.T) {
|
||||
ok := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"foo", "{job_name:eq:foo}"},
|
||||
{"foo*", "{job_name:startwith:foo}"},
|
||||
{"*foo", "{job_name:endwith:foo}"},
|
||||
{"*foo*", "{job_name:contains:foo}"},
|
||||
{"*", "{job_name:contains:}"},
|
||||
{"foo[1]", "{job_name:eq:foo[1]}"},
|
||||
{"foo*[1]", "{job_name:startwith:foo[1]}"},
|
||||
{"*foo[1-3]", "{job_name:endwith:foo[1-3]}"},
|
||||
{"*foo*[2]", "{job_name:contains:foo[2]}"},
|
||||
{"*[1]", "{job_name:contains:[1]}"},
|
||||
{"[1]", "{job_name:eq:[1]}"},
|
||||
{" foo[1] ", "{job_name:eq:foo[1]}"},
|
||||
}
|
||||
for _, tc := range ok {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := &JobsFilter{JobName: tc.name, AllJobInfoFlag: true}
|
||||
got, err := f.HandleJobFilter("")
|
||||
if err != nil {
|
||||
t.Fatalf("HandleJobFilter: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Errorf("filter = %q, want it to contain %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
bad := []struct {
|
||||
name string
|
||||
rule string
|
||||
}{
|
||||
{"foo[1]*", "5.16"},
|
||||
{"foo[1*]", "5.16"},
|
||||
{"foo [1]", "5.16"},
|
||||
{"foo[1", "5.16"},
|
||||
{"foo]", "5.16"},
|
||||
{"foo[a]", "5.16"},
|
||||
{"foo[40000]", "5.7"},
|
||||
{"foo[3-1]", "5.5"},
|
||||
}
|
||||
for _, tc := range bad {
|
||||
t.Run("reject "+tc.name, func(t *testing.T) {
|
||||
f := &JobsFilter{JobName: tc.name, AllJobInfoFlag: true}
|
||||
_, err := f.HandleJobFilter("")
|
||||
if err == nil {
|
||||
t.Fatalf("expected rejection")
|
||||
}
|
||||
se, isSel := err.(*format.SelError)
|
||||
if !isSel || se.Rule != tc.rule {
|
||||
t.Errorf("error %#v, want *SelError rule %s", err, tc.rule)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "'"+strings.TrimSpace(tc.name)+"'") {
|
||||
t.Errorf("error %q does not quote the selection", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// today's wildcard rejection still applies
|
||||
f := &JobsFilter{JobName: "foo%", AllJobInfoFlag: true}
|
||||
if _, err := f.HandleJobFilter(""); err == nil || !strings.Contains(err.Error(), "unsupported wildcard") {
|
||||
t.Errorf("foo%%: err = %v, want unsupported wildcard", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -452,9 +452,10 @@ type JobControlResponseData struct {
|
|||
}
|
||||
|
||||
type JobControlResponseItem struct {
|
||||
JobID int64 `json:"job_id"`
|
||||
ArrayIdx int `json:"array_idx,omitempty"`
|
||||
Msg string `json:"msg"`
|
||||
JobID int64 `json:"job_id"`
|
||||
ArrayIdx int `json:"array_idx,omitempty"`
|
||||
UniqueJobID int64 `json:"unique_job_id,omitempty"` // the result's own job ID (absent from older servers)
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
// job array statistics data structure
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue