-
Notifications
You must be signed in to change notification settings - Fork 2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
cmd: add scale and scaling-events commands to job cmd.
This adds the ability to scale Nomad jobs and view scaling events via the CLI.
- Loading branch information
Showing
5 changed files
with
597 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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] <args> | ||
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().Info(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 []*api.TaskGroup, 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") | ||
} | ||
|
||
// 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 = *groups[0].Name | ||
return nil | ||
} | ||
|
||
// Iterate the groups within the job and ensure the user specified exists. | ||
var found bool | ||
for _, tg := range groups { | ||
if *tg.Name == *group { | ||
found = true | ||
break | ||
} | ||
} | ||
if !found { | ||
return fmt.Errorf("Group %v not found within job", *group) | ||
} | ||
return nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} |
Oops, something went wrong.