Files
eb0e00db97 Add job create, show, and cancel commands to mmctl (#37280)
* Add create, show, and cancel subcommands to mmctl job

The `mmctl job` command previously only supported `list` and `update`.
This adds three subcommands backed by existing client methods:

- `job create [type] [--data k=v]` triggers a new job (CreateJob)
- `job show [job]` displays a single job (GetJob)
- `job cancel [job]` requests cancellation via the dedicated cancel
  endpoint (CancelJob), which uses the create-job permission and emits
  its own audit event, unlike `update [job] cancel_requested`

Also fixes the leftover "import jobs" wording in the `list` flag help
and regenerates the mmctl docs.

* Exercise --data flag parsing in mmctl job create test

The create-job test passed the Data map as the flag's default value, so
GetStringToString returned the default without ever exercising pflag's
stringToString parser. Declare the flag empty and Set the value
explicitly so the test validates the real parse path.

* Don't gate mmctl job create on model.AllJobTypes

model.AllJobTypes (which IsValidJobType checks against) is a curated
subset that omits creatable job types such as access_control_sync, so
validating the type client-side wrongly rejected jobs the server can
schedule. Drop the client-side check and let the server validate the
type per its create-job permissions; it returns a clear error for
anything unschedulable. Tab-completion still suggests AllJobTypes as an
advisory hint.

* Remove explanatory AllJobTypes comments from mmctl job

* Add e2e tests for mmctl job create, show, and cancel

Cover the no-permission, not-found, and success paths for the new
generic job subcommands against a running server, mirroring the existing
export job e2e tests.

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-06 19:07:08 +02:00

304 lines
7.9 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
"fmt"
"time"
"github.com/hashicorp/go-multierror"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/client"
"github.com/mattermost/mattermost/server/v8/cmd/mmctl/printer"
"github.com/spf13/cobra"
)
var JobCmd = &cobra.Command{
Use: "job",
Short: "Management of jobs",
}
var listJobsCmd = &cobra.Command{
Use: "list",
Short: "List the latest jobs",
Example: ` job list
job list --ids jobID1,jobID2
job list --type ldap_sync --status success
job list --type ldap_sync --status success --page 0 --per-page 10`,
Args: cobra.NoArgs,
RunE: withClient(listJobsCmdF),
}
var createJobCmd = &cobra.Command{
Use: "create [type]",
Short: "Create a new job",
Long: "Create a new job of the given type. Use --data to pass type-specific options as key=value pairs.",
Example: ` job create ldap_sync
job create data_retention
job create message_export --data batch_size=1000`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: jobTypeCompletionF,
RunE: withClient(createJobCmdF),
}
var showJobCmd = &cobra.Command{
Use: "show [job]",
Short: "Show a job",
Example: " job show jobID",
Args: cobra.ExactArgs(1),
ValidArgsFunction: validateArgsWithClient(jobCompletionF),
RunE: withClient(showJobCmdF),
}
var cancelJobCmd = &cobra.Command{
Use: "cancel [job]",
Short: "Cancel a job",
Example: " job cancel jobID",
Args: cobra.ExactArgs(1),
ValidArgsFunction: validateArgsWithClient(jobCompletionF),
RunE: withClient(cancelJobCmdF),
}
var updateJobCmd = &cobra.Command{
Use: "update [job] [status]",
Short: "Update the status of a job",
Long: `Update the status of a job. The following restrictions are in place:
- in_progress -> pending
- in_progress | pending -> cancel_requested
- cancel_requested -> canceled
Those restriction can be bypassed with --force=true but the only statuses you can go to are: pending, cancel_requested and canceled. This can have unexpected consequences and should be used with caution.`,
Example: ` job update myJobID pending
job update myJobID pending --force true
job update myJobID canceled --force true`,
Args: cobra.MinimumNArgs(2),
RunE: withClient(updateJobCmdF),
}
func init() {
listJobsCmd.Flags().Int("page", 0, "Page number to fetch for the list of jobs")
listJobsCmd.Flags().Int("per-page", 5, "Number of jobs to be fetched")
listJobsCmd.Flags().Bool("all", false, "Fetch all jobs. --page flag will be ignored if provided")
listJobsCmd.Flags().StringSlice("ids", nil, "Comma-separated list of job IDs to which the operation will be applied. All other flags are ignored")
listJobsCmd.Flags().String("status", "", "Filter by job status")
listJobsCmd.Flags().String("type", "", "Filter by job type")
createJobCmd.Flags().StringToString("data", nil, "Comma-separated list of key=value pairs passed to the job as type-specific options")
updateJobCmd.Flags().Bool("force", false, "Setting a job status is restricted to certain statuses. You can overwrite these restrictions by using --force. This might cause unexpected behaviour on your Mattermost Server. Use this option with caution.")
JobCmd.AddCommand(
listJobsCmd,
createJobCmd,
showJobCmd,
cancelJobCmd,
updateJobCmd,
)
RootCmd.AddCommand(JobCmd)
}
func listJobsCmdF(c client.Client, cmd *cobra.Command, args []string) error {
ids, err := cmd.Flags().GetStringSlice("ids")
if err != nil {
return err
}
jobType, err := cmd.Flags().GetString("type")
if err != nil {
return err
}
status, err := cmd.Flags().GetString("status")
if err != nil {
return err
}
if len(ids) > 0 {
jobs := make([]*model.Job, 0, len(ids))
var result *multierror.Error
for _, id := range ids {
isValidId := model.IsValidId(id)
if !isValidId {
result = multierror.Append(result, fmt.Errorf("invalid job ID: %s", id))
continue
}
job, _, err := c.GetJob(context.TODO(), id)
if err != nil {
result = multierror.Append(result, err)
continue
}
jobs = append(jobs, job)
}
for _, job := range jobs {
printJob(job)
}
return result.ErrorOrNil()
}
return jobListCmdF(c, cmd, jobType, status)
}
func updateJobCmdF(c client.Client, cmd *cobra.Command, args []string) error {
force, err := cmd.Flags().GetBool("force")
if err != nil {
return err
}
jobId := args[0]
if !model.IsValidId(jobId) {
return fmt.Errorf("invalid job ID: %s", jobId)
}
status := args[1]
if !model.IsValidJobStatus(status) {
return fmt.Errorf("invalid job status: %s", status)
}
_, err = c.UpdateJobStatus(context.TODO(), jobId, status, force)
if err != nil {
return err
}
return nil
}
func createJobCmdF(c client.Client, cmd *cobra.Command, args []string) error {
jobType := args[0]
data, err := cmd.Flags().GetStringToString("data")
if err != nil {
return err
}
job, _, err := c.CreateJob(context.TODO(), &model.Job{
Type: jobType,
Data: data,
})
if err != nil {
return fmt.Errorf("failed to create job: %w", err)
}
printer.PrintT("Job successfully created, ID: {{.Id}}", job)
return nil
}
func showJobCmdF(c client.Client, cmd *cobra.Command, args []string) error {
jobId := args[0]
if !model.IsValidId(jobId) {
return fmt.Errorf("invalid job ID: %s", jobId)
}
job, _, err := c.GetJob(context.TODO(), jobId)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
printJob(job)
return nil
}
func cancelJobCmdF(c client.Client, cmd *cobra.Command, args []string) error {
jobId := args[0]
if !model.IsValidId(jobId) {
return fmt.Errorf("invalid job ID: %s", jobId)
}
if _, err := c.CancelJob(context.TODO(), jobId); err != nil {
return fmt.Errorf("failed to cancel job: %w", err)
}
return nil
}
func jobTypeCompletionF(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return model.AllJobTypes[:], cobra.ShellCompDirectiveNoFileComp
}
func jobCompletionF(ctx context.Context, c client.Client, cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return fetchAndComplete(
func(ctx context.Context, c client.Client, page, perPage int) ([]*model.Job, *model.Response, error) {
return c.GetJobs(ctx, "", "", page, perPage)
},
func(j *model.Job) []string { return []string{j.Id} },
)(ctx, c, cmd, args, toComplete)
}
func jobListCmdF(c client.Client, command *cobra.Command, jobType string, status string) error {
page, err := command.Flags().GetInt("page")
if err != nil {
return err
}
perPage, err := command.Flags().GetInt("per-page")
if err != nil {
return err
}
showAll, err := command.Flags().GetBool("all")
if err != nil {
return err
}
if showAll {
page = 0
}
if jobType != "" && !model.IsValidJobType(jobType) {
return fmt.Errorf("invalid job type: %s", jobType)
}
if status != "" && !model.IsValidJobStatus(status) {
return fmt.Errorf("invalid job status: %s", status)
}
for {
jobs, _, err := c.GetJobs(context.TODO(), jobType, status, page, perPage)
if err != nil {
return fmt.Errorf("failed to get jobs: %w", err)
}
if len(jobs) == 0 {
if !showAll || page == 0 {
printer.Print("No jobs found")
}
return nil
}
for _, job := range jobs {
printJob(job)
}
if !showAll {
break
}
page++
}
return nil
}
func printJob(job *model.Job) {
if job.StartAt > 0 {
printer.PrintT(fmt.Sprintf(` ID: {{.Id}}
Type: {{.Type}}
Status: {{.Status}}
Created: %s
Started: %s
Data: {{.Data}}
`,
time.Unix(job.CreateAt/1000, 0), time.Unix(job.StartAt/1000, 0)), job)
} else {
printer.PrintT(fmt.Sprintf(` ID: {{.Id}}
Status: {{.Status}}
Created: %s
`,
time.Unix(job.CreateAt/1000, 0)), job)
}
}