cli: add recommendation commands.
Adds new CLI commands for applying, dismissing and detailing Nomad recommendations under a new top level recommendation command.
This commit is contained in:
parent
67bbd3770f
commit
fe92d8b3cb
|
@ -648,6 +648,32 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
|
|||
}, nil
|
||||
},
|
||||
|
||||
"recommendation": func() (cli.Command, error) {
|
||||
return &RecommendationCommand{
|
||||
Meta: meta,
|
||||
}, nil
|
||||
},
|
||||
"recommendation apply": func() (cli.Command, error) {
|
||||
return &RecommendationApplyCommand{
|
||||
Meta: meta,
|
||||
}, nil
|
||||
},
|
||||
"recommendation dismiss": func() (cli.Command, error) {
|
||||
return &RecommendationDismissCommand{
|
||||
Meta: meta,
|
||||
}, nil
|
||||
},
|
||||
"recommendation info": func() (cli.Command, error) {
|
||||
return &RecommendationInfoCommand{
|
||||
Meta: meta,
|
||||
}, nil
|
||||
},
|
||||
"recommendation list": func() (cli.Command, error) {
|
||||
return &RecommendationListCommand{
|
||||
Meta: meta,
|
||||
}, nil
|
||||
},
|
||||
|
||||
"run": func() (cli.Command, error) {
|
||||
return &JobRunCommand{
|
||||
Meta: meta,
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mitchellh/cli"
|
||||
)
|
||||
|
||||
// Ensure RecommendationCommand satisfies the cli.Command interface.
|
||||
var _ cli.Command = &RecommendationCommand{}
|
||||
|
||||
// RecommendationCommand implements cli.Command.
|
||||
type RecommendationCommand struct {
|
||||
Meta
|
||||
}
|
||||
|
||||
// Help satisfies the cli.Command Help function.
|
||||
func (r *RecommendationCommand) Help() string {
|
||||
helpText := `
|
||||
Usage: nomad recommendation <subcommand> [options]
|
||||
|
||||
This command groups subcommands for interacting with the recommendation API.
|
||||
|
||||
Please see the individual subcommand help for detailed usage information.
|
||||
`
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
|
||||
// Synopsis satisfies the cli.Command Synopsis function.
|
||||
func (r *RecommendationCommand) Synopsis() string {
|
||||
return "Interact with the Nomad recommendation endpoint"
|
||||
}
|
||||
|
||||
// Name returns the name of this command.
|
||||
func (r *RecommendationCommand) Name() string { return "recommendation" }
|
||||
|
||||
// Run satisfies the cli.Command Run function.
|
||||
func (r *RecommendationCommand) Run(_ []string) int { return cli.RunResultHelp }
|
|
@ -0,0 +1,159 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/nomad/api"
|
||||
"github.com/mitchellh/cli"
|
||||
"github.com/posener/complete"
|
||||
)
|
||||
|
||||
// Ensure RecommendationApplyCommand satisfies the cli.Command interface.
|
||||
var _ cli.Command = &RecommendationApplyCommand{}
|
||||
|
||||
// RecommendationApplyCommand implements cli.Command.
|
||||
type RecommendationApplyCommand struct {
|
||||
Meta
|
||||
}
|
||||
|
||||
// Help satisfies the cli.Command Help function.
|
||||
func (r *RecommendationApplyCommand) Help() string {
|
||||
helpText := `
|
||||
Usage: nomad recommendation apply [options] <recommendation_ids>
|
||||
|
||||
Apply one or more Nomad recommendations.
|
||||
|
||||
General Options:
|
||||
|
||||
` + generalOptionsUsage() + `
|
||||
|
||||
Recommendation Apply Options:
|
||||
|
||||
-detach
|
||||
Return immediately instead of entering monitor mode. After applying a
|
||||
recommendation, the evaluation ID will be printed to the screen, which can
|
||||
be used to examine the evaluation using the eval-status command. If applying
|
||||
recommendations for multiple jobs, this value will always be true.
|
||||
|
||||
-policy-override
|
||||
If set, any soft mandatory Sentinel policies will be overridden. This allows
|
||||
a recommendation to be applied when it would be denied by a policy.
|
||||
|
||||
-verbose
|
||||
Display full information.
|
||||
`
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
|
||||
// Synopsis satisfies the cli.Command Synopsis function.
|
||||
func (r *RecommendationApplyCommand) Synopsis() string {
|
||||
return "Apply one or more Nomad recommendations"
|
||||
}
|
||||
|
||||
func (r *RecommendationApplyCommand) AutocompleteFlags() complete.Flags {
|
||||
return mergeAutocompleteFlags(r.Meta.AutocompleteFlags(FlagSetClient),
|
||||
complete.Flags{
|
||||
"-detach": complete.PredictNothing,
|
||||
"-policy-override": complete.PredictNothing,
|
||||
"-verbose": complete.PredictNothing,
|
||||
})
|
||||
}
|
||||
|
||||
// Name returns the name of this command.
|
||||
func (r *RecommendationApplyCommand) Name() string { return "recommendation apply" }
|
||||
|
||||
// Run satisfies the cli.Command Run function.
|
||||
func (r *RecommendationApplyCommand) Run(args []string) int {
|
||||
var detach, override, verbose bool
|
||||
|
||||
flags := r.Meta.FlagSet(r.Name(), FlagSetClient)
|
||||
flags.Usage = func() { r.Ui.Output(r.Help()) }
|
||||
flags.BoolVar(&override, "policy-override", false, "")
|
||||
flags.BoolVar(&detach, "detach", false, "")
|
||||
flags.BoolVar(&verbose, "verbose", false, "")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 1
|
||||
}
|
||||
|
||||
if args = flags.Args(); len(args) < 1 {
|
||||
r.Ui.Error("This command takes at least one argument: <recommendation_id>")
|
||||
r.Ui.Error(commandErrorText(r))
|
||||
return 1
|
||||
}
|
||||
|
||||
// Get the HTTP client.
|
||||
client, err := r.Meta.Client()
|
||||
if err != nil {
|
||||
r.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
// Create a list of recommendations to apply.
|
||||
ids := make([]string, len(args))
|
||||
for i, id := range args {
|
||||
ids[i] = id
|
||||
}
|
||||
|
||||
resp, _, err := client.Recommendations().Apply(ids, override)
|
||||
if err != nil {
|
||||
r.Ui.Error(fmt.Sprintf("Error applying recommendations: %v", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
// If we should detach, or must because we applied multiple recommendations
|
||||
// resulting in more than a single eval to monitor.
|
||||
if detach || len(resp.Errors) > 0 || len(resp.UpdatedJobs) > 1 {
|
||||
|
||||
// If we had apply errors, output these at the top so they are easy to
|
||||
// find. Always output the heading, this provides some consistency,
|
||||
// even if just to show there are no errors.
|
||||
r.Ui.Output(r.Colorize().Color("[bold]Errors[reset]"))
|
||||
if len(resp.Errors) > 0 {
|
||||
r.outputApplyErrors(resp.Errors)
|
||||
} else {
|
||||
r.Ui.Output("None\n")
|
||||
}
|
||||
|
||||
// If we had apply results, output these.
|
||||
if len(resp.UpdatedJobs) > 0 {
|
||||
r.outputApplyResult(resp.UpdatedJobs)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// When would we ever reach this case? Probably never, but catch this just
|
||||
// in case.
|
||||
if len(resp.UpdatedJobs) < 1 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// If we reached here, we should have a single entry to interrogate and
|
||||
// monitor.
|
||||
length := shortId
|
||||
if verbose {
|
||||
length = fullId
|
||||
}
|
||||
mon := newMonitor(r.Ui, client, length)
|
||||
return mon.monitor(resp.UpdatedJobs[0].EvalID)
|
||||
}
|
||||
|
||||
func (r *RecommendationApplyCommand) outputApplyErrors(errs []*api.SingleRecommendationApplyError) {
|
||||
output := []string{"IDs|Job ID|Error"}
|
||||
for _, err := range errs {
|
||||
output = append(output, fmt.Sprintf("%s|%s|%s", err.Recommendations, err.JobID, err.Error))
|
||||
}
|
||||
r.Ui.Output(formatList(output))
|
||||
r.Ui.Output("\n")
|
||||
}
|
||||
|
||||
func (r *RecommendationApplyCommand) outputApplyResult(res []*api.SingleRecommendationApplyResult) {
|
||||
output := []string{"IDs|Namespace|Job ID|Eval ID|Warnings"}
|
||||
for _, r := range res {
|
||||
output = append(output, fmt.Sprintf(
|
||||
"%s|%s|%s|%s|%s",
|
||||
strings.Join(r.Recommendations, ","), r.Namespace, r.JobID, r.EvalID, r.Warnings))
|
||||
}
|
||||
r.Ui.Output(r.Colorize().Color("[bold]Results[reset]"))
|
||||
r.Ui.Output(formatList(output))
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/api"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/mitchellh/cli"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRecommendationApplyCommand_Run(t *testing.T) {
|
||||
require := require.New(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 := &RecommendationApplyCommand{Meta: Meta{Ui: ui}}
|
||||
|
||||
// Register a test job to write a recommendation against.
|
||||
testJob := testJob("recommendation_apply")
|
||||
regResp, _, err := client.Jobs().Register(testJob, nil)
|
||||
require.NoError(err)
|
||||
registerCode := waitForSuccess(ui, client, fullId, t, regResp.EvalID)
|
||||
require.Equal(0, registerCode)
|
||||
|
||||
// Write a recommendation.
|
||||
rec := api.Recommendation{
|
||||
JobID: *testJob.ID,
|
||||
Group: *testJob.TaskGroups[0].Name,
|
||||
Task: testJob.TaskGroups[0].Tasks[0].Name,
|
||||
Resource: "CPU",
|
||||
Value: 1,
|
||||
Meta: map[string]interface{}{"test-meta-entry": "test-meta-value"},
|
||||
Stats: map[string]float64{"p13": 1.13},
|
||||
}
|
||||
recResp, _, err := client.Recommendations().Upsert(&rec, nil)
|
||||
if srv.Enterprise {
|
||||
require.NoError(err)
|
||||
|
||||
// Read the recommendation out to ensure it is there as a control on
|
||||
// later tests.
|
||||
recInfo, _, err := client.Recommendations().Info(recResp.ID, nil)
|
||||
require.NoError(err)
|
||||
require.NotNil(recInfo)
|
||||
} else {
|
||||
require.Error(err, "Nomad Enterprise only endpoint")
|
||||
}
|
||||
|
||||
// Only perform the call if we are running enterprise tests. Otherwise the
|
||||
// recResp object will be nil.
|
||||
if !srv.Enterprise {
|
||||
return
|
||||
}
|
||||
code := cmd.Run([]string{"-address=" + url, recResp.ID})
|
||||
require.Equal(0, code)
|
||||
|
||||
// Perform an info call on the recommendation which should return not
|
||||
// found.
|
||||
recInfo, _, err := client.Recommendations().Info(recResp.ID, nil)
|
||||
require.Error(err, "not found")
|
||||
require.Nil(recInfo)
|
||||
|
||||
// Check the new jobspec to see if the resource value has changed.
|
||||
jobResp, _, err := client.Jobs().Info(*testJob.ID, nil)
|
||||
require.NoError(err)
|
||||
require.Equal(1, *jobResp.TaskGroups[0].Tasks[0].Resources.CPU)
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mitchellh/cli"
|
||||
"github.com/posener/complete"
|
||||
)
|
||||
|
||||
// Ensure RecommendationDismissCommand satisfies the cli.Command interface.
|
||||
var _ cli.Command = &RecommendationDismissCommand{}
|
||||
|
||||
// RecommendationDismissCommand implements cli.Command.
|
||||
type RecommendationDismissCommand struct {
|
||||
Meta
|
||||
}
|
||||
|
||||
// Help satisfies the cli.Command Help function.
|
||||
func (r *RecommendationDismissCommand) Help() string {
|
||||
helpText := `
|
||||
Usage: nomad recommendation dismiss [options] <recommendation_ids>
|
||||
|
||||
Dismiss one or more Nomad recommendations.
|
||||
|
||||
General Options:
|
||||
|
||||
` + generalOptionsUsage()
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
|
||||
// Synopsis satisfies the cli.Command Synopsis function.
|
||||
func (r *RecommendationDismissCommand) Synopsis() string {
|
||||
return "Dismiss one or more Nomad recommendations"
|
||||
}
|
||||
|
||||
func (r *RecommendationDismissCommand) AutocompleteFlags() complete.Flags {
|
||||
return mergeAutocompleteFlags(r.Meta.AutocompleteFlags(FlagSetClient),
|
||||
complete.Flags{})
|
||||
}
|
||||
|
||||
// Name returns the name of this command.
|
||||
func (r *RecommendationDismissCommand) Name() string { return "recommendation dismiss" }
|
||||
|
||||
// Run satisfies the cli.Command Run function.
|
||||
func (r *RecommendationDismissCommand) Run(args []string) int {
|
||||
|
||||
flags := r.Meta.FlagSet(r.Name(), FlagSetClient)
|
||||
flags.Usage = func() { r.Ui.Output(r.Help()) }
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 1
|
||||
}
|
||||
|
||||
if args = flags.Args(); len(args) < 1 {
|
||||
r.Ui.Error("This command takes at least one argument: <recommendation_id>")
|
||||
r.Ui.Error(commandErrorText(r))
|
||||
return 1
|
||||
}
|
||||
|
||||
// Get the HTTP client.
|
||||
client, err := r.Meta.Client()
|
||||
if err != nil {
|
||||
r.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
// Create a list of recommendations to dismiss.
|
||||
ids := make([]string, len(args))
|
||||
for i, id := range args {
|
||||
ids[i] = id
|
||||
}
|
||||
|
||||
_, err = client.Recommendations().Delete(ids, nil)
|
||||
if err != nil {
|
||||
r.Ui.Error(fmt.Sprintf("Error dismissing recommendations: %v", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
verb := "recommendation"
|
||||
if len(ids) > 1 {
|
||||
verb += "s"
|
||||
}
|
||||
r.Ui.Output(fmt.Sprintf("Successfully dismissed %s", verb))
|
||||
return 0
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/api"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/mitchellh/cli"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRecommendationDismissCommand_Run(t *testing.T) {
|
||||
require := require.New(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 := &RecommendationDismissCommand{Meta: Meta{Ui: ui}}
|
||||
|
||||
// Register a test job to write a recommendation against.
|
||||
testJob := testJob("recommendation_dismiss")
|
||||
regResp, _, err := client.Jobs().Register(testJob, nil)
|
||||
require.NoError(err)
|
||||
registerCode := waitForSuccess(ui, client, fullId, t, regResp.EvalID)
|
||||
require.Equal(0, registerCode)
|
||||
|
||||
// Write a recommendation.
|
||||
rec := api.Recommendation{
|
||||
JobID: *testJob.ID,
|
||||
Group: *testJob.TaskGroups[0].Name,
|
||||
Task: testJob.TaskGroups[0].Tasks[0].Name,
|
||||
Resource: "CPU",
|
||||
Value: 1050,
|
||||
Meta: map[string]interface{}{"test-meta-entry": "test-meta-value"},
|
||||
Stats: map[string]float64{"p13": 1.13},
|
||||
}
|
||||
recResp, _, err := client.Recommendations().Upsert(&rec, nil)
|
||||
if srv.Enterprise {
|
||||
require.NoError(err)
|
||||
|
||||
// Read the recommendation out to ensure it is there as a control on
|
||||
// later tests.
|
||||
recInfo, _, err := client.Recommendations().Info(recResp.ID, nil)
|
||||
require.NoError(err)
|
||||
require.NotNil(recInfo)
|
||||
} else {
|
||||
require.Error(err, "Nomad Enterprise only endpoint")
|
||||
}
|
||||
|
||||
// Only perform the call if we are running enterprise tests. Otherwise the
|
||||
// recResp object will be nil.
|
||||
if !srv.Enterprise {
|
||||
return
|
||||
}
|
||||
code := cmd.Run([]string{"-address=" + url, recResp.ID})
|
||||
require.Equal(0, code)
|
||||
out := ui.OutputWriter.String()
|
||||
require.Contains(out, "Successfully dismissed recommendation")
|
||||
|
||||
// Perform an info call on the recommendation which should return not
|
||||
// found.
|
||||
recInfo, _, err := client.Recommendations().Info(recResp.ID, nil)
|
||||
require.Error(err, "not found")
|
||||
require.Nil(recInfo)
|
||||
}
|
|
@ -0,0 +1,169 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mitchellh/cli"
|
||||
"github.com/posener/complete"
|
||||
)
|
||||
|
||||
// Ensure RecommendationInfoCommand satisfies the cli.Command interface.
|
||||
var _ cli.Command = &RecommendationInfoCommand{}
|
||||
|
||||
// RecommendationInfoCommand implements cli.Command.
|
||||
type RecommendationInfoCommand struct {
|
||||
Meta
|
||||
}
|
||||
|
||||
// Help satisfies the cli.Command Help function.
|
||||
func (r *RecommendationInfoCommand) Help() string {
|
||||
helpText := `
|
||||
Usage: nomad recommendation info [options] <recommendation_id>
|
||||
|
||||
Info is used to read the specified recommendation.
|
||||
|
||||
General Options:
|
||||
|
||||
` + generalOptionsUsage() + `
|
||||
|
||||
Recommendation Info Options:
|
||||
|
||||
-json
|
||||
Output the recommendation in its JSON format.
|
||||
|
||||
-t
|
||||
Format and display the recommendation using a Go template.
|
||||
`
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
|
||||
// Synopsis satisfies the cli.Command Synopsis function.
|
||||
func (r *RecommendationInfoCommand) Synopsis() string {
|
||||
return "Display an individual Nomad recommendation"
|
||||
}
|
||||
|
||||
func (r *RecommendationInfoCommand) AutocompleteFlags() complete.Flags {
|
||||
return mergeAutocompleteFlags(r.Meta.AutocompleteFlags(FlagSetClient),
|
||||
complete.Flags{
|
||||
"-json": complete.PredictNothing,
|
||||
"-t": complete.PredictAnything,
|
||||
})
|
||||
}
|
||||
|
||||
// Name returns the name of this command.
|
||||
func (r *RecommendationInfoCommand) Name() string { return "recommendation info" }
|
||||
|
||||
// Run satisfies the cli.Command Run function.
|
||||
func (r *RecommendationInfoCommand) Run(args []string) int {
|
||||
var json bool
|
||||
var tmpl string
|
||||
|
||||
flags := r.Meta.FlagSet(r.Name(), FlagSetClient)
|
||||
flags.Usage = func() { r.Ui.Output(r.Help()) }
|
||||
flags.BoolVar(&json, "json", false, "")
|
||||
flags.StringVar(&tmpl, "t", "", "")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 1
|
||||
}
|
||||
|
||||
if args = flags.Args(); len(args) != 1 {
|
||||
r.Ui.Error("This command takes one argument: <recommendation_id>")
|
||||
r.Ui.Error(commandErrorText(r))
|
||||
return 1
|
||||
}
|
||||
|
||||
// Get the recommendation ID.
|
||||
recID := args[0]
|
||||
|
||||
// Get the HTTP client.
|
||||
client, err := r.Meta.Client()
|
||||
if err != nil {
|
||||
r.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
rec, _, err := client.Recommendations().Info(recID, nil)
|
||||
if err != nil {
|
||||
r.Ui.Error(fmt.Sprintf("Error reading recommendation: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
// If the user has specified to output the recommendation as JSON or using
|
||||
// a template then perform this action for the entire object and exit the
|
||||
// command.
|
||||
if json || len(tmpl) > 0 {
|
||||
out, err := Format(json, tmpl, rec)
|
||||
if err != nil {
|
||||
r.Ui.Error(err.Error())
|
||||
return 1
|
||||
}
|
||||
r.Ui.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
info := []string{
|
||||
fmt.Sprintf("ID|%s", rec.ID),
|
||||
fmt.Sprintf("Namespace|%s", rec.Namespace),
|
||||
fmt.Sprintf("Job ID|%s", rec.JobID),
|
||||
fmt.Sprintf("Task Group|%s", rec.Group),
|
||||
fmt.Sprintf("Task|%s", rec.Task),
|
||||
fmt.Sprintf("Resource|%s", rec.Resource),
|
||||
fmt.Sprintf("Value|%v", rec.Value),
|
||||
fmt.Sprintf("Current|%v", rec.Current),
|
||||
}
|
||||
r.Ui.Output(formatKV(info))
|
||||
|
||||
// If we have stats, format and output these.
|
||||
if len(rec.Stats) > 0 {
|
||||
|
||||
// Sort the stats keys into an alphabetically ordered list to provide
|
||||
// consistent outputs.
|
||||
keys := []string{}
|
||||
|
||||
for k := range rec.Stats {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
// We will only need two rows; key:value.
|
||||
output := make([]string, 2)
|
||||
|
||||
for _, stat := range keys {
|
||||
output[0] += fmt.Sprintf("%s|", stat)
|
||||
output[1] += fmt.Sprintf("%.2f|", rec.Stats[stat])
|
||||
}
|
||||
|
||||
// Trim any trailing pipes so we can use the formatList function thus
|
||||
// providing a nice clean output.
|
||||
output[0] = strings.TrimRight(output[0], "|")
|
||||
output[1] = strings.TrimRight(output[1], "|")
|
||||
|
||||
r.Ui.Output(r.Colorize().Color("\n[bold]Stats[reset]"))
|
||||
r.Ui.Output(formatList(output))
|
||||
}
|
||||
|
||||
// If we have meta, format and output the entries.
|
||||
if len(rec.Meta) > 0 {
|
||||
|
||||
// Sort the meta keys into an alphabetically ordered list to provide
|
||||
// consistent outputs.
|
||||
keys := []string{}
|
||||
|
||||
for k := range rec.Meta {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
output := make([]string, len(rec.Meta))
|
||||
|
||||
for i, key := range keys {
|
||||
output[i] = fmt.Sprintf("%s|%v", key, rec.Meta[key])
|
||||
}
|
||||
r.Ui.Output(r.Colorize().Color("\n[bold]Meta[reset]"))
|
||||
r.Ui.Output(formatKV(output))
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/api"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/mitchellh/cli"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRecommendationInfoCommand_Run(t *testing.T) {
|
||||
require := require.New(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 := &RecommendationInfoCommand{Meta: Meta{Ui: ui}}
|
||||
|
||||
// Perform an initial call, which should return a not found error.
|
||||
code := cmd.Run([]string{"-address=" + url, "2c13f001-f5b6-ce36-03a5-e37afe160df5"})
|
||||
if srv.Enterprise {
|
||||
require.Equal(1, code)
|
||||
out := ui.ErrorWriter.String()
|
||||
require.Contains(out, "Recommendation not found")
|
||||
} else {
|
||||
require.Equal(1, code)
|
||||
require.Contains(ui.ErrorWriter.String(), "Nomad Enterprise only endpoint")
|
||||
}
|
||||
|
||||
// Register a test job to write a recommendation against.
|
||||
testJob := testJob("recommendation_info")
|
||||
regResp, _, err := client.Jobs().Register(testJob, nil)
|
||||
require.NoError(err)
|
||||
registerCode := waitForSuccess(ui, client, fullId, t, regResp.EvalID)
|
||||
require.Equal(0, registerCode)
|
||||
|
||||
// Write a recommendation.
|
||||
rec := api.Recommendation{
|
||||
JobID: *testJob.ID,
|
||||
Group: *testJob.TaskGroups[0].Name,
|
||||
Task: testJob.TaskGroups[0].Tasks[0].Name,
|
||||
Resource: "CPU",
|
||||
Value: 1050,
|
||||
Meta: map[string]interface{}{"test-meta-entry": "test-meta-value"},
|
||||
Stats: map[string]float64{"p13": 1.13},
|
||||
}
|
||||
recResp, _, err := client.Recommendations().Upsert(&rec, nil)
|
||||
if srv.Enterprise {
|
||||
require.NoError(err)
|
||||
} else {
|
||||
require.Error(err, "Nomad Enterprise only endpoint")
|
||||
}
|
||||
|
||||
// Only perform the call if we are running enterprise tests. Otherwise the
|
||||
// recResp object will be nil.
|
||||
if srv.Enterprise {
|
||||
code = cmd.Run([]string{"-address=" + url, recResp.ID})
|
||||
require.Equal(0, code)
|
||||
out := ui.OutputWriter.String()
|
||||
require.Contains(out, "test-meta-entry")
|
||||
require.Contains(out, "p13")
|
||||
require.Contains(out, "1.13")
|
||||
require.Contains(out, recResp.ID)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,202 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/nomad/api"
|
||||
"github.com/mitchellh/cli"
|
||||
"github.com/posener/complete"
|
||||
)
|
||||
|
||||
// Ensure RecommendationListCommand satisfies the cli.Command interface.
|
||||
var _ cli.Command = &RecommendationListCommand{}
|
||||
|
||||
// RecommendationListCommand implements cli.Command.
|
||||
type RecommendationListCommand struct {
|
||||
Meta
|
||||
}
|
||||
|
||||
// Help satisfies the cli.Command Help function.
|
||||
func (r *RecommendationListCommand) Help() string {
|
||||
helpText := `
|
||||
Usage: nomad recommendation list [options]
|
||||
|
||||
List is used to list the available recommendations.
|
||||
|
||||
General Options:
|
||||
|
||||
` + generalOptionsUsage() + `
|
||||
|
||||
Recommendation List Options:
|
||||
|
||||
-job
|
||||
Specifies the job ID to filter the recommendations list by.
|
||||
|
||||
-group
|
||||
Specifies the task group name to filter within a job. If specified, the -job
|
||||
flag must also be specified.
|
||||
|
||||
-task
|
||||
Specifies the task name to filter within a job and task group. If specified,
|
||||
the -job and -group flags must also be specified.
|
||||
|
||||
-json
|
||||
Output the recommendations in JSON format.
|
||||
|
||||
-t
|
||||
Format and display the recommendations using a Go template.
|
||||
`
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
|
||||
// Synopsis satisfies the cli.Command Synopsis function.
|
||||
func (r *RecommendationListCommand) Synopsis() string {
|
||||
return "Display all Nomad recommendations"
|
||||
}
|
||||
|
||||
func (r *RecommendationListCommand) AutocompleteFlags() complete.Flags {
|
||||
return mergeAutocompleteFlags(r.Meta.AutocompleteFlags(FlagSetClient),
|
||||
complete.Flags{
|
||||
"-job": complete.PredictNothing,
|
||||
"-group": complete.PredictNothing,
|
||||
"-task": complete.PredictNothing,
|
||||
"-json": complete.PredictNothing,
|
||||
"-t": complete.PredictAnything,
|
||||
})
|
||||
}
|
||||
|
||||
// Name returns the name of this command.
|
||||
func (r *RecommendationListCommand) Name() string { return "recommendation list" }
|
||||
|
||||
// Run satisfies the cli.Command Run function.
|
||||
func (r *RecommendationListCommand) Run(args []string) int {
|
||||
var json bool
|
||||
var tmpl, job, group, task string
|
||||
|
||||
flags := r.Meta.FlagSet(r.Name(), FlagSetClient)
|
||||
flags.Usage = func() { r.Ui.Output(r.Help()) }
|
||||
flags.BoolVar(&json, "json", false, "")
|
||||
flags.StringVar(&tmpl, "t", "", "")
|
||||
flags.StringVar(&job, "job", "", "")
|
||||
flags.StringVar(&group, "group", "", "")
|
||||
flags.StringVar(&task, "task", "", "")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 1
|
||||
}
|
||||
|
||||
if args = flags.Args(); len(args) > 0 {
|
||||
r.Ui.Error("This command takes no arguments")
|
||||
r.Ui.Error(commandErrorText(r))
|
||||
}
|
||||
|
||||
// Get the HTTP client.
|
||||
client, err := r.Meta.Client()
|
||||
if err != nil {
|
||||
r.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
// Validate the input flags. This is done by the HTTP API anyway, but there
|
||||
// is no harm doing it here to avoid calls that we know wont succeed.
|
||||
if group != "" && job == "" {
|
||||
r.Ui.Error("Job flag must be supplied when using group flag")
|
||||
return 1
|
||||
|
||||
}
|
||||
if task != "" && group == "" {
|
||||
r.Ui.Error("Group flag must be supplied when using task flag")
|
||||
return 1
|
||||
}
|
||||
|
||||
// Setup the query params.
|
||||
q := &api.QueryOptions{
|
||||
Params: map[string]string{},
|
||||
}
|
||||
if job != "" {
|
||||
q.Params["job"] = job
|
||||
}
|
||||
if group != "" {
|
||||
q.Params["group"] = group
|
||||
}
|
||||
if task != "" {
|
||||
q.Params["task"] = task
|
||||
}
|
||||
|
||||
recommendations, _, err := client.Recommendations().List(q)
|
||||
if err != nil {
|
||||
r.Ui.Error(fmt.Sprintf("Error listing recommendations: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
if len(recommendations) == 0 {
|
||||
r.Ui.Output("No recommendations found")
|
||||
return 0
|
||||
}
|
||||
|
||||
if json || len(tmpl) > 0 {
|
||||
out, err := Format(json, tmpl, recommendations)
|
||||
if err != nil {
|
||||
r.Ui.Error(err.Error())
|
||||
return 1
|
||||
}
|
||||
r.Ui.Output(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Create the output table header.
|
||||
output := []string{"ID|"}
|
||||
|
||||
// If the operator is using the namespace wildcard option, add this header.
|
||||
if r.Meta.namespace == "*" {
|
||||
output[0] += "Namespace|"
|
||||
}
|
||||
output[0] += "Job|Group|Task|Resource|Value"
|
||||
|
||||
// Sort the list of recommendations based on their job, group and task.
|
||||
sortedRecs := recommendationList{r: recommendations}
|
||||
sort.Sort(sortedRecs)
|
||||
|
||||
// Iterate the recommendations and add to the output.
|
||||
for i, rec := range sortedRecs.r {
|
||||
|
||||
output = append(output, rec.ID)
|
||||
|
||||
if r.Meta.namespace == "*" {
|
||||
output[i+1] += fmt.Sprintf("|%s", rec.Namespace)
|
||||
}
|
||||
output[i+1] += fmt.Sprintf("|%s|%s|%s|%s|%v", rec.JobID, rec.Group, rec.Task, rec.Resource, rec.Value)
|
||||
}
|
||||
|
||||
// Output.
|
||||
r.Ui.Output(formatList(output))
|
||||
return 0
|
||||
}
|
||||
|
||||
// recommendationList is a wrapper around []*api.Recommendation that lets us
|
||||
// sort the recommendations alphabetically based on their job, group and task.
|
||||
type recommendationList struct {
|
||||
r []*api.Recommendation
|
||||
}
|
||||
|
||||
// Len satisfies the Len function of the sort.Interface interface.
|
||||
func (r recommendationList) Len() int { return len(r.r) }
|
||||
|
||||
// Swap satisfies the Swap function of the sort.Interface interface.
|
||||
func (r recommendationList) Swap(i, j int) {
|
||||
r.r[i], r.r[j] = r.r[j], r.r[i]
|
||||
}
|
||||
|
||||
// Less satisfies the Less function of the sort.Interface interface.
|
||||
func (r recommendationList) Less(i, j int) bool {
|
||||
recI := r.stringFromResource(i)
|
||||
recJ := r.stringFromResource(j)
|
||||
stringList := []string{recI, recJ}
|
||||
sort.Strings(stringList)
|
||||
return stringList[0] == recI
|
||||
}
|
||||
|
||||
func (r recommendationList) stringFromResource(i int) string {
|
||||
return strings.Join([]string{r.r[i].Namespace, r.r[i].JobID, r.r[i].Group, r.r[i].Task, r.r[i].Resource}, ":")
|
||||
}
|
|
@ -0,0 +1,175 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/api"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/mitchellh/cli"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRecommendationListCommand_Run(t *testing.T) {
|
||||
require := require.New(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 := &RecommendationListCommand{Meta: Meta{Ui: ui}}
|
||||
|
||||
// Perform an initial list, which should return zero results.
|
||||
code := cmd.Run([]string{"-address=" + url})
|
||||
if srv.Enterprise {
|
||||
require.Equal(0, code)
|
||||
out := ui.OutputWriter.String()
|
||||
require.Contains(out, "No recommendations found")
|
||||
} else {
|
||||
require.Equal(1, code)
|
||||
require.Contains(ui.ErrorWriter.String(), "Nomad Enterprise only endpoint")
|
||||
}
|
||||
|
||||
// Register a test job to write a recommendation against.
|
||||
testJob := testJob("recommendation_list")
|
||||
regResp, _, err := client.Jobs().Register(testJob, nil)
|
||||
require.NoError(err)
|
||||
registerCode := waitForSuccess(ui, client, fullId, t, regResp.EvalID)
|
||||
require.Equal(0, registerCode)
|
||||
|
||||
// Write a recommendation.
|
||||
rec := api.Recommendation{
|
||||
JobID: *testJob.ID,
|
||||
Group: *testJob.TaskGroups[0].Name,
|
||||
Task: testJob.TaskGroups[0].Tasks[0].Name,
|
||||
Resource: "CPU",
|
||||
Value: 1050,
|
||||
Meta: map[string]interface{}{"test-meta-entry": "test-meta-value"},
|
||||
Stats: map[string]float64{"p13": 1.13},
|
||||
}
|
||||
_, _, err = client.Recommendations().Upsert(&rec, nil)
|
||||
if srv.Enterprise {
|
||||
require.NoError(err)
|
||||
} else {
|
||||
require.Error(err, "Nomad Enterprise only endpoint")
|
||||
}
|
||||
|
||||
// Perform a new list which should yield results.
|
||||
code = cmd.Run([]string{"-address=" + url})
|
||||
if srv.Enterprise {
|
||||
require.Equal(0, code)
|
||||
out := ui.OutputWriter.String()
|
||||
require.Contains(out, "ID")
|
||||
require.Contains(out, "Job")
|
||||
require.Contains(out, "Group")
|
||||
require.Contains(out, "Task")
|
||||
require.Contains(out, "Resource")
|
||||
require.Contains(out, "Value")
|
||||
require.Contains(out, "CPU")
|
||||
} else {
|
||||
require.Equal(1, code)
|
||||
require.Contains(ui.ErrorWriter.String(), "Nomad Enterprise only endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationList_Sort(t *testing.T) {
|
||||
testCases := []struct {
|
||||
inputRecommendationList []*api.Recommendation
|
||||
expectedOutputList []*api.Recommendation
|
||||
name string
|
||||
}{
|
||||
{
|
||||
inputRecommendationList: []*api.Recommendation{
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
},
|
||||
expectedOutputList: []*api.Recommendation{
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
},
|
||||
name: "single job with both resources",
|
||||
},
|
||||
{
|
||||
inputRecommendationList: []*api.Recommendation{
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "mongodb", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "mongodb", Resource: "CPU"},
|
||||
},
|
||||
expectedOutputList: []*api.Recommendation{
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "mongodb", Resource: "CPU"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "mongodb", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
},
|
||||
name: "single job with multiple groups",
|
||||
},
|
||||
{
|
||||
inputRecommendationList: []*api.Recommendation{
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
{Namespace: "default", JobID: "distro", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "mongodb", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "mongodb", Resource: "CPU"},
|
||||
{Namespace: "default", JobID: "distro", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
},
|
||||
expectedOutputList: []*api.Recommendation{
|
||||
{Namespace: "default", JobID: "distro", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
{Namespace: "default", JobID: "distro", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "mongodb", Resource: "CPU"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "mongodb", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
},
|
||||
name: "multiple jobs",
|
||||
},
|
||||
{
|
||||
inputRecommendationList: []*api.Recommendation{
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
{Namespace: "cefault", JobID: "distro", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "distro", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "mongodb", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "mongodb", Resource: "CPU"},
|
||||
{Namespace: "default", JobID: "distro", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
{Namespace: "cefault", JobID: "distro", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
},
|
||||
expectedOutputList: []*api.Recommendation{
|
||||
{Namespace: "cefault", JobID: "distro", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
{Namespace: "cefault", JobID: "distro", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "distro", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
{Namespace: "default", JobID: "distro", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "mongodb", Resource: "CPU"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "mongodb", Resource: "MemoryMB"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "CPU"},
|
||||
{Namespace: "default", JobID: "example", Group: "cache", Task: "redis", Resource: "MemoryMB"},
|
||||
},
|
||||
name: "multiple namespaces",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sortedRecs := recommendationList{r: tc.inputRecommendationList}
|
||||
sort.Sort(sortedRecs)
|
||||
assert.Equal(t, tc.expectedOutputList, sortedRecs.r, tc.name)
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue