feat(xcecli): jobs kill --signal and --force
The control API already takes `signal` (kill only: that signal once, instead of the graceful INT, TERM, KILL sequence) and `force_kill` (SIGKILL at once); xcecli could not send either. `jobs kill` now has -s/--signal <name|number> (names in any case, SIG prefix optional, sent upper case without it) and --force, in both the job-ID and the filter request; using both at once, or an empty signal, is refused before any request. stop and resume get neither (the server rejects a signal there). Kill help and docs/xce.md "Killing and stopping jobs" document the flags. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0379823098
commit
954c96cf5b
3 changed files with 178 additions and 7 deletions
|
|
@ -64,9 +64,15 @@ Job control follows LSF's TERMINATE and SUSPEND actions:
|
|||
- **Stop** (`xcecli jobs stop <id>`): SIGTSTP first, so the job can catch
|
||||
it, prepare and stop itself; SIGSTOP follows after the interval if it has
|
||||
not stopped. **Resume** sends SIGCONT and cancels a stop still in progress.
|
||||
- **Explicit signal**: that signal only, once (REST API: `signal` in the
|
||||
control request, kill action; `xcecli` has no signal option yet).
|
||||
- **Force kill**: SIGKILL at once (REST API: `force_kill: true`).
|
||||
- **Explicit signal**: that signal only, once (`xcecli jobs kill -s
|
||||
<name|number> <id>`, e.g. `-s TERM`, `-s SIGUSR1`, `-s 9`; REST API:
|
||||
`signal` in the control request, kill action only).
|
||||
- **Force kill**: SIGKILL at once (`xcecli jobs kill --force <id>`; REST
|
||||
API: `force_kill: true`).
|
||||
|
||||
`--signal` and `--force` work with job IDs and with the filters (`-J`, `-q`,
|
||||
`-u`, `-a`), and cannot be used together. `stop` and `resume` take no
|
||||
signal.
|
||||
|
||||
The interval is the API server's `job.terminate_interval` setting
|
||||
(`config/app.yaml`, seconds; default 10 when unset or 0, as LSF's
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ type JobController struct {
|
|||
action string
|
||||
filter models.JobControlFilter
|
||||
jobIDs []string
|
||||
signal string // kill only: send this signal once (number or name)
|
||||
forceKill bool // kill only: SIGKILL at once
|
||||
}
|
||||
|
||||
// NewJobController create job controller
|
||||
|
|
@ -61,7 +63,9 @@ func (c *JobController) controlJobsByIDs(apiClient *request.APIClient, token str
|
|||
url := fmt.Sprintf("/xce/v1/jobs/%s/control", strings.Join(apiIDs, ","))
|
||||
|
||||
req := models.JobControlJobIDRequest{
|
||||
Action: c.action,
|
||||
Action: c.action,
|
||||
Signal: c.signal,
|
||||
ForceKill: c.forceKill,
|
||||
}
|
||||
// encode request data to JSON bytes
|
||||
return c.printJobControlResponse(req, apiClient, token, url)
|
||||
|
|
@ -79,8 +83,10 @@ func (c *JobController) controlJobsByFilter(apiClient *request.APIClient, token
|
|||
url := "/xce/v1/jobs/control"
|
||||
|
||||
req := models.JobControlRequest{
|
||||
Action: c.action,
|
||||
Filter: c.filter,
|
||||
Action: c.action,
|
||||
Filter: c.filter,
|
||||
Signal: c.signal,
|
||||
ForceKill: c.forceKill,
|
||||
}
|
||||
// encode request data to JSON bytes
|
||||
return c.printJobControlResponse(req, apiClient, token, url)
|
||||
|
|
@ -143,6 +149,22 @@ func (c *JobController) ParseCmdArgs(cmd *cobra.Command, args []string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// parseKillOptions checks the kill-only -s/--signal and --force options:
|
||||
// they cannot be combined, and a signal name is sent in the API server's
|
||||
// form (upper case, without the SIG prefix; the server checks it is known).
|
||||
func (c *JobController) parseKillOptions(cmd *cobra.Command) error {
|
||||
signalSet := cmd != nil && cmd.Flags().Changed("signal")
|
||||
if signalSet && c.forceKill {
|
||||
return fmt.Errorf("--signal and --force cannot be used together")
|
||||
}
|
||||
c.signal = strings.ToUpper(strings.TrimSpace(c.signal))
|
||||
if signalSet && c.signal == "" {
|
||||
return fmt.Errorf("empty signal: give a signal name or number")
|
||||
}
|
||||
c.signal = strings.TrimPrefix(c.signal, "SIG")
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewKillJobsCmd create kill job command
|
||||
func NewKillJobsCmd(configManager *config.ConfigManager) *cobra.Command {
|
||||
controller := NewJobController(configManager, "kill")
|
||||
|
|
@ -156,6 +178,11 @@ The job gets SIGINT, then SIGTERM, then SIGKILL, one interval apart (the
|
|||
API server's job.terminate_interval, default 10 s), each only while it is
|
||||
still running, so it can catch SIGINT/SIGTERM and clean up.
|
||||
|
||||
-s, --signal <name|number> send only this signal, once, instead of that
|
||||
sequence (e.g. TERM, SIGUSR1, 9)
|
||||
--force send SIGKILL at once
|
||||
--signal and --force cannot be used together.
|
||||
|
||||
Examples:
|
||||
xce jobs kill jobid1,jobid2,... # kill jobs by job IDs
|
||||
xce jobs kill <array-id>[k] # kill element k of an array
|
||||
|
|
@ -163,12 +190,18 @@ Examples:
|
|||
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`,
|
||||
xce jobs kill -u username # kill jobs by user
|
||||
xce jobs kill -s USR1 jobid # send SIGUSR1 to a job, once
|
||||
xce jobs kill --force jobid # SIGKILL a job at once`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := controller.ParseCmdArgs(cmd, args); err != nil {
|
||||
fmt.Println(strings.ToLower(err.Error()))
|
||||
return nil
|
||||
}
|
||||
if err := controller.parseKillOptions(cmd); err != nil {
|
||||
fmt.Println(strings.ToLower(err.Error()))
|
||||
return nil
|
||||
}
|
||||
if err := controller.RunJobsControl(); err != nil {
|
||||
fmt.Println(strings.ToLower(err.Error()))
|
||||
return nil
|
||||
|
|
@ -183,6 +216,8 @@ Examples:
|
|||
flags.StringVarP(&controller.filter.QueueName, "queue", "q", "", "filter by queue name")
|
||||
flags.StringVarP(&controller.filter.Users, "user", "u", "", "filter by user")
|
||||
flags.BoolVarP(&controller.filter.AllJobs, "all", "a", false, "select all jobs")
|
||||
flags.StringVarP(&controller.signal, "signal", "s", "", "send only this signal (name or number), once")
|
||||
flags.BoolVar(&controller.forceKill, "force", false, "send SIGKILL at once")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ import (
|
|||
|
||||
"xcecli/common/format"
|
||||
"xcecli/common/request"
|
||||
"xcecli/config"
|
||||
"xcecli/models"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Array-capable identity used by the tests below: tag 0, sequence 32768, so
|
||||
|
|
@ -232,3 +235,130 @@ func TestListHelpShowsElementForms(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bodyServer is an httptest API server that records every request as
|
||||
// "PATH BODY" and answers with an empty control result.
|
||||
type bodyServer struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
reqs []string
|
||||
}
|
||||
|
||||
func newBodyServer(t *testing.T) *bodyServer {
|
||||
t.Helper()
|
||||
bs := &bodyServer{}
|
||||
bs.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
bs.mu.Lock()
|
||||
bs.reqs = append(bs.reqs, r.URL.Path+" "+string(body))
|
||||
bs.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(models.JobControlResponse{Code: 200, Msg: "ok",
|
||||
Data: &models.JobControlResponseData{}})
|
||||
}))
|
||||
t.Cleanup(bs.srv.Close)
|
||||
return bs
|
||||
}
|
||||
|
||||
func (bs *bodyServer) requests() []string {
|
||||
bs.mu.Lock()
|
||||
defer bs.mu.Unlock()
|
||||
return append([]string(nil), bs.reqs...)
|
||||
}
|
||||
|
||||
// runJobsCmd runs a jobs control command against url and returns its output.
|
||||
func runJobsCmd(t *testing.T, newCmd func(*config.ConfigManager) *cobra.Command, url string, args ...string) string {
|
||||
t.Helper()
|
||||
cm := config.NewConfigManagerWithPath(t.TempDir() + "/config.json")
|
||||
if err := cm.SaveConfig(&config.Config{APIServerInfo: []config.APIServerInfo{{URL: url, Token: "tok"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := newCmd(cm)
|
||||
cmd.SetArgs(args)
|
||||
out, err := captureStdout(t, cmd.Execute)
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %v", args, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// kill -s/--signal and --force reach the request body, in both the job-ID
|
||||
// and the filter form; a signal name may be given in any case, with or
|
||||
// without the SIG prefix.
|
||||
func TestKillSignalAndForceInRequestBody(t *testing.T) {
|
||||
byID := "/xce/v1/jobs/" + strconv.FormatUint(testArrayKey, 10) + "[1]/control "
|
||||
byFilter := "/xce/v1/jobs/control "
|
||||
cases := []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{[]string{testArrayWritten + "[1]"}, byID + `{"action":"kill"}`},
|
||||
{[]string{"-s", "term", testArrayWritten + "[1]"}, byID + `{"action":"kill","signal":"TERM"}`},
|
||||
{[]string{"--signal", "SIGUSR1", testArrayWritten + "[1]"}, byID + `{"action":"kill","signal":"USR1"}`},
|
||||
{[]string{"-s", "9", testArrayWritten + "[1]"}, byID + `{"action":"kill","signal":"9"}`},
|
||||
{[]string{"--force", testArrayWritten + "[1]"}, byID + `{"action":"kill","force_kill":true}`},
|
||||
{[]string{"-s", "Int", "-J", "foo"}, byFilter + `{"filter":{"job_name":"foo"},"action":"kill","signal":"INT"}`},
|
||||
{[]string{"--force", "-u", "bob"}, byFilter + `{"filter":{"users":"bob"},"action":"kill","force_kill":true}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
|
||||
bs := newBodyServer(t)
|
||||
out := runJobsCmd(t, NewKillJobsCmd, bs.srv.URL, tc.args...)
|
||||
if got := bs.requests(); len(got) != 1 || got[0] != tc.want {
|
||||
t.Fatalf("requests = %q, want [%q] (output %q)", got, tc.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --signal and --force together, or an empty signal, are refused before any
|
||||
// request is sent.
|
||||
func TestKillSignalAndForceRejected(t *testing.T) {
|
||||
cases := []struct {
|
||||
args []string
|
||||
wantSub string
|
||||
}{
|
||||
{[]string{"-s", "TERM", "--force", testArrayWritten + "[1]"}, "cannot be used together"},
|
||||
{[]string{"--force", "--signal", "9", "-J", "foo"}, "cannot be used together"},
|
||||
{[]string{"-s", " ", testArrayWritten + "[1]"}, "empty signal"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
|
||||
bs := newBodyServer(t)
|
||||
out := runJobsCmd(t, NewKillJobsCmd, bs.srv.URL, tc.args...)
|
||||
if !strings.Contains(out, tc.wantSub) {
|
||||
t.Errorf("output %q does not contain %q", out, tc.wantSub)
|
||||
}
|
||||
if n := len(bs.requests()); n != 0 {
|
||||
t.Errorf("%d request(s) sent, want none", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The signal options belong to kill only (the API server rejects a signal
|
||||
// for stop and resume), and the kill help documents them.
|
||||
func TestKillSignalOptionsOnlyOnKill(t *testing.T) {
|
||||
kill := NewKillJobsCmd(nil)
|
||||
for _, name := range []string{"signal", "force"} {
|
||||
if kill.Flags().Lookup(name) == nil {
|
||||
t.Errorf("kill lacks --%s", name)
|
||||
}
|
||||
if !strings.Contains(kill.Long, "--"+name) {
|
||||
t.Errorf("kill help does not document --%s", name)
|
||||
}
|
||||
}
|
||||
if f := kill.Flags().Lookup("signal"); f != nil && f.Shorthand != "s" {
|
||||
t.Errorf("--signal shorthand = %q, want s", f.Shorthand)
|
||||
}
|
||||
for name, cmd := range map[string]*cobra.Command{
|
||||
"stop": NewStopJobsCmd(nil),
|
||||
"resume": NewResumeJobsCmd(nil),
|
||||
} {
|
||||
for _, flag := range []string{"signal", "force"} {
|
||||
if cmd.Flags().Lookup(flag) != nil {
|
||||
t.Errorf("%s has --%s", name, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue