Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cli: add scale and scaling-events commands to job cmd. #9023

Merged
merged 1 commit into from Oct 6, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions command/commands.go
Expand Up @@ -357,6 +357,16 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
Meta: meta,
}, nil
},
"job scale": func() (cli.Command, error) {
return &JobScaleCommand{
Meta: meta,
}, nil
},
"job scaling-events": func() (cli.Command, error) {
return &JobScalingEventsCommand{
Meta: meta,
}, nil
},
"job status": func() (cli.Command, error) {
return &JobStatusCommand{
Meta: meta,
Expand Down
185 changes: 185 additions & 0 deletions command/job_scale.go
@@ -0,0 +1,185 @@
package command

import (
"errors"
"fmt"
"strconv"
"strings"

"github.com/hashicorp/nomad/api"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)

// Ensure JobScaleCommand satisfies the cli.Command interface.
var _ cli.Command = &JobScaleCommand{}

// JobScaleCommand implements cli.Command.
type JobScaleCommand struct {
Meta
}

// Help satisfies the cli.Command Help function.
func (j *JobScaleCommand) Help() string {
helpText := `
Usage: nomad job scale [options] <job> [<group>] <count>
Perform a scaling action by altering the count within a job group.
Upon successful job submission, this command will immediately
enter an interactive monitor. This is useful to watch Nomad's
internals make scheduling decisions and place the submitted work
onto nodes. The monitor will end once job placement is done. It
is safe to exit the monitor early using ctrl+c.
General Options:
` + generalOptionsUsage() + `
Scale Options:
-detach
Return immediately instead of entering monitor mode. After job scaling,
the evaluation ID will be printed to the screen, which can be used to
examine the evaluation using the eval-status command.
-verbose
Display full information.
`
return strings.TrimSpace(helpText)
}

// Synopsis satisfies the cli.Command Synopsis function.
func (j *JobScaleCommand) Synopsis() string {
return "Change the count of a Nomad job group"
}

func (j *JobScaleCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(j.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-detach": complete.PredictNothing,
"-verbose": complete.PredictNothing,
})
}

// Name returns the name of this command.
func (j *JobScaleCommand) Name() string { return "job scale" }

// Run satisfies the cli.Command Run function.
func (j *JobScaleCommand) Run(args []string) int {
var detach, verbose bool

flags := j.Meta.FlagSet(j.Name(), FlagSetClient)
flags.Usage = func() { j.Ui.Output(j.Help()) }
flags.BoolVar(&detach, "detach", false, "")
flags.BoolVar(&verbose, "verbose", false, "")
if err := flags.Parse(args); err != nil {
return 1
}

var jobString, countString, groupString string
args = flags.Args()

// It is possible to specify either 2 or 3 arguments. Check and assign the
// args so they can be validate later on.
if numArgs := len(args); numArgs < 2 || numArgs > 3 {
j.Ui.Error("Command requires at least two arguments and no more than three")
return 1
} else if numArgs == 3 {
groupString = args[1]
countString = args[2]
} else {
countString = args[1]
}
jobString = args[0]

// Convert the count string arg to an int as required by the API.
count, err := strconv.Atoi(countString)
if err != nil {
j.Ui.Error(fmt.Sprintf("Failed to convert count string to int: %s", err))
return 1
}

// Get the HTTP client.
client, err := j.Meta.Client()
if err != nil {
j.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}

// Detail the job so we can perform addition checks before submitting the
// scaling request.
job, _, err := client.Jobs().ScaleStatus(jobString, nil)
if err != nil {
j.Ui.Error(fmt.Sprintf("Error querying job: %v", err))
return 1
}

if err := j.performGroupCheck(job.TaskGroups, &groupString); err != nil {
j.Ui.Error(err.Error())
return 1
}

// This is our default message added to scaling submissions.
msg := "submitted using the Nomad CLI"

// Perform the scaling action.
resp, _, err := client.Jobs().Scale(jobString, groupString, &count, msg, false, nil, nil)
if err != nil {
j.Ui.Error(fmt.Sprintf("Error submitting scaling request: %s", err))
return 1
}

// Print any warnings if we have some.
if resp.Warnings != "" {
j.Ui.Output(
j.Colorize().Color(fmt.Sprintf("[bold][yellow]Job Warnings:\n%s[reset]\n", resp.Warnings)))
}

// If we are to detach, log the evaluation ID and exit.
if detach {
j.Ui.Output("Evaluation ID: " + resp.EvalID)
return 0
}

// Truncate the ID unless full length is requested.
length := shortId
if verbose {
length = fullId
}

// Create and monitor the evaluation.
mon := newMonitor(j.Ui, client, length)
return mon.monitor(resp.EvalID, false)
}

// performGroupCheck performs logic to ensure the user specified the correct
// group argument.
func (j *JobScaleCommand) performGroupCheck(groups map[string]api.TaskGroupScaleStatus, group *string) error {

// If the job contains multiple groups and the user did not supply a task
// group, return an error.
if len(groups) > 1 && *group == "" {
return errors.New("Group name required")
}

// We have to iterate the map to have any idea what task groups we are
// dealing with.
for groupName := range groups {

// If the job has a single task group, and the user did not supply a
// task group, it is assumed we scale the only group in the job.
if len(groups) == 1 && *group == "" {
*group = groupName
return nil
}

// If we found a match, return.
if groupName == *group {
return nil
}
}

// If we got here, we didn't find a match and therefore return an error.
return fmt.Errorf("Group %v not found within job", *group)
}
123 changes: 123 additions & 0 deletions command/job_scale_test.go
@@ -0,0 +1,123 @@
package command

import (
"fmt"
"strings"
"testing"

"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/helper"
"github.com/hashicorp/nomad/testutil"
"github.com/mitchellh/cli"
)

func TestJobScaleCommand_SingleGroup(t *testing.T) {
t.Parallel()
srv, client, url := testServer(t, true, nil)
defer srv.Shutdown()
testutil.WaitForResult(func() (bool, error) {
nodes, _, err := client.Nodes().List(nil)
if err != nil {
return false, err
}
if len(nodes) == 0 {
return false, fmt.Errorf("missing node")
}
if _, ok := nodes[0].Drivers["mock_driver"]; !ok {
return false, fmt.Errorf("mock_driver not ready")
}
return true, nil
}, func(err error) {
t.Fatalf("err: %s", err)
})

ui := cli.NewMockUi()
cmd := &JobScaleCommand{Meta: Meta{Ui: ui}}

// Register a test job and ensure it is running before moving on.
resp, _, err := client.Jobs().Register(testJob("scale_cmd_single_group"), nil)
if err != nil {
t.Fatalf("err: %s", err)
}
if code := waitForSuccess(ui, client, fullId, t, resp.EvalID); code != 0 {
t.Fatalf("expected waitForSuccess exit code 0, got: %d", code)
}

// Perform the scaling action.
if code := cmd.Run([]string{"-address=" + url, "-detach", "scale_cmd_single_group", "2"}); code != 0 {
t.Fatalf("expected cmd run exit code 0, got: %d", code)
}
if out := ui.OutputWriter.String(); !strings.Contains(out, "Evaluation ID:") {
t.Fatalf("Expected Evaluation ID within output: %v", out)
}
}

func TestJobScaleCommand_MultiGroup(t *testing.T) {
t.Parallel()
srv, client, url := testServer(t, true, nil)
defer srv.Shutdown()
testutil.WaitForResult(func() (bool, error) {
nodes, _, err := client.Nodes().List(nil)
if err != nil {
return false, err
}
if len(nodes) == 0 {
return false, fmt.Errorf("missing node")
}
if _, ok := nodes[0].Drivers["mock_driver"]; !ok {
return false, fmt.Errorf("mock_driver not ready")
}
return true, nil
}, func(err error) {
t.Fatalf("err: %s", err)
})

ui := cli.NewMockUi()
cmd := &JobScaleCommand{Meta: Meta{Ui: ui}}

// Create a job with two task groups.
job := testJob("scale_cmd_multi_group")
task := api.NewTask("task2", "mock_driver").
SetConfig("kill_after", "1s").
SetConfig("run_for", "5s").
SetConfig("exit_code", 0).
Require(&api.Resources{
MemoryMB: helper.IntToPtr(256),
CPU: helper.IntToPtr(100),
}).
SetLogConfig(&api.LogConfig{
MaxFiles: helper.IntToPtr(1),
MaxFileSizeMB: helper.IntToPtr(2),
})
group2 := api.NewTaskGroup("group2", 1).
AddTask(task).
RequireDisk(&api.EphemeralDisk{
SizeMB: helper.IntToPtr(20),
})
job.AddTaskGroup(group2)

// Register a test job and ensure it is running before moving on.
resp, _, err := client.Jobs().Register(job, nil)
if err != nil {
t.Fatalf("err: %s", err)
}
if code := waitForSuccess(ui, client, fullId, t, resp.EvalID); code != 0 {
t.Fatalf("expected waitForSuccess exit code 0, got: %d", code)
}

// Attempt to scale without specifying the task group which should fail.
if code := cmd.Run([]string{"-address=" + url, "-detach", "scale_cmd_multi_group", "2"}); code != 1 {
t.Fatalf("expected cmd run exit code 1, got: %d", code)
}
if out := ui.ErrorWriter.String(); !strings.Contains(out, "Group name required") {
t.Fatalf("unexpected error message: %v", out)
}

// Specify the target group which should be successful.
if code := cmd.Run([]string{"-address=" + url, "-detach", "scale_cmd_multi_group", "group1", "2"}); code != 0 {
t.Fatalf("expected cmd run exit code 0, got: %d", code)
}
if out := ui.OutputWriter.String(); !strings.Contains(out, "Evaluation ID:") {
t.Fatalf("Expected Evaluation ID within output: %v", out)
}
}