From 06fc2846449d139a06f1205a378b59c720044743 Mon Sep 17 00:00:00 2001 From: Tim Gross Date: Tue, 6 Jun 2023 15:02:26 -0400 Subject: [PATCH] node pools: implement CLI for `node pool jobs` command (#17432) --- command/commands.go | 5 + command/node_pool.go | 23 +++ command/node_pool_info.go | 26 +-- command/node_pool_jobs.go | 162 ++++++++++++++++++ command/node_pool_jobs_test.go | 143 ++++++++++++++++ .../content/docs/commands/node-pool/index.mdx | 3 + .../content/docs/commands/node-pool/jobs.mdx | 79 +++++++++ website/content/partials/general_options.mdx | 4 +- website/data/docs-nav-data.json | 4 + 9 files changed, 422 insertions(+), 27 deletions(-) create mode 100644 command/node_pool_jobs.go create mode 100644 command/node_pool_jobs_test.go create mode 100644 website/content/docs/commands/node-pool/jobs.mdx diff --git a/command/commands.go b/command/commands.go index 942acef0f..01c3aea78 100644 --- a/command/commands.go +++ b/command/commands.go @@ -636,6 +636,11 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory { Meta: meta, }, nil }, + "node pool jobs": func() (cli.Command, error) { + return &NodePoolJobsCommand{ + Meta: meta, + }, nil + }, "node pool list": func() (cli.Command, error) { return &NodePoolListCommand{ Meta: meta, diff --git a/command/node_pool.go b/command/node_pool.go index 10e0b2e77..fa8e5eda5 100644 --- a/command/node_pool.go +++ b/command/node_pool.go @@ -99,3 +99,26 @@ func nodePoolPredictor(factory ApiClientFactory, filter *set.Set[string]) comple return filtered }) } + +// nodePoolByPrefix returns a node pool that matches the given prefix or a list +// of all matches if an exact match is not found. +func nodePoolByPrefix(client *api.Client, prefix string) (*api.NodePool, []*api.NodePool, error) { + pools, _, err := client.NodePools().PrefixList(prefix, nil) + if err != nil { + return nil, nil, err + } + + switch len(pools) { + case 0: + return nil, nil, fmt.Errorf("No node pool with prefix %q found", prefix) + case 1: + return pools[0], nil, nil + default: + for _, pool := range pools { + if pool.Name == prefix { + return pool, nil, nil + } + } + return nil, pools, nil + } +} diff --git a/command/node_pool_info.go b/command/node_pool_info.go index 6c848d3c5..34766e8e2 100644 --- a/command/node_pool_info.go +++ b/command/node_pool_info.go @@ -8,7 +8,6 @@ import ( "sort" "strings" - "github.com/hashicorp/nomad/api" "github.com/posener/complete" ) @@ -89,7 +88,7 @@ func (c *NodePoolInfoCommand) Run(args []string) int { return 1 } - pool, possible, err := c.nodePoolByPrefix(client, args[0]) + pool, possible, err := nodePoolByPrefix(client, args[0]) if err != nil { c.Ui.Error(fmt.Sprintf("Error retrieving node pool: %s", err)) return 1 @@ -142,26 +141,3 @@ func (c *NodePoolInfoCommand) Run(args []string) int { return 0 } - -// nodePoolByPrefix returns a node pool that matches the given prefix or a list -// of all matches if an exact match is not found. -func (c *NodePoolInfoCommand) nodePoolByPrefix(client *api.Client, prefix string) (*api.NodePool, []*api.NodePool, error) { - pools, _, err := client.NodePools().PrefixList(prefix, nil) - if err != nil { - return nil, nil, err - } - - switch len(pools) { - case 0: - return nil, nil, fmt.Errorf("No node pool with prefix %q found", prefix) - case 1: - return pools[0], nil, nil - default: - for _, pool := range pools { - if pool.Name == prefix { - return pool, nil, nil - } - } - return nil, pools, nil - } -} diff --git a/command/node_pool_jobs.go b/command/node_pool_jobs.go new file mode 100644 index 000000000..f0a37f41f --- /dev/null +++ b/command/node_pool_jobs.go @@ -0,0 +1,162 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package command + +import ( + "fmt" + "os" + "strings" + + "github.com/posener/complete" + + "github.com/hashicorp/nomad/api" +) + +type NodePoolJobsCommand struct { + Meta +} + +func (c *NodePoolJobsCommand) Name() string { + return "node pool jobs" +} + +func (c *NodePoolJobsCommand) Synopsis() string { + return "Fetch a list of jobs in a node pool" +} + +func (c *NodePoolJobsCommand) Help() string { + helpText := ` +Usage: nomad node pool jobs + + Node pool jobs is used to list jobs in a given node pool. + + If ACLs are enabled, this command requires a token with the 'read' capability + in a 'node_pool' policy that matches the node pool being targeted. The results + will be filtered by the namespaces where the token has 'read-job' capability. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault) + ` + +Node Pool Jobs Options: + + -filter + Specifies an expression used to filter jobs from the results. + The filter is applied to the job and not the node pool. + + -json + Output the list in JSON format. + + -page-token + Where to start pagination. + + -per-page + How many results to show per page. If not specified, or set to 0, all + results are returned. + + -t + Format and display jobs list using a Go template. +` + + return strings.TrimSpace(helpText) +} + +func (c *NodePoolJobsCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-filter": complete.PredictAnything, + "-json": complete.PredictNothing, + "-page-token": complete.PredictAnything, + "-per-page": complete.PredictAnything, + "-t": complete.PredictAnything, + }) +} + +func (c *NodePoolJobsCommand) AutocompleteArgs() complete.Predictor { + return nodePoolPredictor(c.Client, nil) +} + +func (c *NodePoolJobsCommand) Run(args []string) int { + var json bool + var perPage int + var pageToken, filter, tmpl string + + flags := c.Meta.FlagSet(c.Name(), FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + flags.BoolVar(&json, "json", false, "") + flags.StringVar(&filter, "filter", "", "") + flags.StringVar(&pageToken, "page-token", "", "") + flags.IntVar(&perPage, "per-page", 0, "") + flags.StringVar(&tmpl, "t", "", "") + + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that we only have one argument. + args = flags.Args() + if len(args) != 1 { + c.Ui.Error("This command takes one argument: ") + c.Ui.Error(commandErrorText(c)) + return 1 + } + + // Lookup node pool by prefix. + client, err := c.Meta.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + pool, possible, err := nodePoolByPrefix(client, args[0]) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error retrieving node pool: %s", err)) + return 1 + } + if len(possible) != 0 { + c.Ui.Error(fmt.Sprintf( + "Prefix matched multiple node pools\n\n%s", formatNodePoolList(possible))) + return 1 + } + + opts := &api.QueryOptions{ + Filter: filter, + PerPage: int32(perPage), + NextToken: pageToken, + } + + jobs, qm, err := client.NodePools().ListJobs(pool.Name, opts) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error querying jobs: %s", err)) + return 1 + } + + if len(jobs) == 0 { + c.Ui.Output("No jobs") + return 0 + } + + // Format output if requested. + if json || tmpl != "" { + out, err := Format(json, tmpl, jobs) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + c.Ui.Output(out) + return 0 + } + + c.Ui.Output(createStatusListOutput(jobs, c.allNamespaces())) + + if qm.NextToken != "" { + c.Ui.Output(fmt.Sprintf(` + Results have been paginated. To get the next page run: + + %s -page-token %s`, argsWithoutPageToken(os.Args), qm.NextToken)) + } + + return 0 +} diff --git a/command/node_pool_jobs_test.go b/command/node_pool_jobs_test.go new file mode 100644 index 000000000..415cb3dd3 --- /dev/null +++ b/command/node_pool_jobs_test.go @@ -0,0 +1,143 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package command + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/mitchellh/cli" + "github.com/shoenig/test" + "github.com/shoenig/test/must" + + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/helper" + "github.com/hashicorp/nomad/helper/pointer" +) + +func TestNodePoolJobsListCommand_Run(t *testing.T) { + ci.Parallel(t) + + // Start test server. + srv, client, url := testServer(t, true, nil) + defer srv.Shutdown() + + waitForNodes(t, client) + + // Register test node pool + dev1 := &api.NodePool{Name: "dev-1", Description: "Pool dev-1"} + _, err := client.NodePools().Register(dev1, nil) + must.NoError(t, err) + + // Register a non-default namespace + ns := &api.Namespace{Name: "system"} + _, err = client.Namespaces().Register(ns, nil) + must.NoError(t, err) + + // Register some jobs + registerJob := func(np, ns, id string) { + job := testJob(id) + job.Namespace = pointer.Of(ns) + job.NodePool = pointer.Of(np) + _, _, err := client.Jobs().Register(job, nil) + must.NoError(t, err) + } + + registerJob("dev-1", "default", "job0") + registerJob("dev-1", "default", "job1") + registerJob("dev-1", "system", "job2") + registerJob("default", "default", "job3") + registerJob("default", "default", "job4") + registerJob("default", "system", "job5") + + testCases := []struct { + name string + args []string + expectedJobs []string + expectedErr string + expectedCode int + }{ + { + name: "missing arg", + args: []string{}, + expectedErr: "This command takes one argument: ", + expectedCode: 1, + }, + { + name: "list with wildcard namespaces", + args: []string{"-namespace", "*", "dev-1"}, + expectedJobs: []string{"job0", "job1", "job2"}, + expectedCode: 0, + }, + { + name: "list with specific namespaces", + args: []string{"-namespace", "system", "dev-1"}, + expectedJobs: []string{"job2"}, + expectedCode: 0, + }, + { + name: "list with specific namespaces in all pools", + args: []string{"-namespace", "system", "all"}, + expectedJobs: []string{"job2", "job5"}, + expectedCode: 0, + }, + { + name: "list with filter", + args: []string{"-filter", "ID == \"job1\"", "dev-1"}, + expectedJobs: []string{"job1"}, + expectedCode: 0, + }, + { + name: "paginate", + args: []string{"-per-page", "2", "-namespace", "*", "dev-1"}, + expectedJobs: []string{"job0", "job1"}, + expectedCode: 0, + }, + { + name: "paginate page 2", + args: []string{ + "-per-page", "2", "-page-token", "job2", + "-namespace", "*", "dev-1"}, + expectedJobs: []string{"job2"}, + expectedCode: 0, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Initialize UI and command. + ui := cli.NewMockUi() + cmd := &NodePoolJobsCommand{Meta: Meta{Ui: ui}} + + // Run command, always in JSON so we can easily parse results + args := []string{"-address", url, "-json"} + args = append(args, tc.args...) + code := cmd.Run(args) + + gotStdout := ui.OutputWriter.String() + gotStdout = jsonOutputRaftIndexes.ReplaceAllString(gotStdout, "") + + must.Eq(t, tc.expectedCode, code, + must.Sprintf("got unexpected code with stdout:\n%s\nstderr:\n%s", + gotStdout, ui.ErrorWriter.String(), + )) + + if tc.expectedCode == 0 { + var jobs []*api.JobListStub + err := json.Unmarshal([]byte(gotStdout), &jobs) + must.NoError(t, err) + + gotJobs := helper.ConvertSlice(jobs, + func(j *api.JobListStub) string { return j.ID }) + must.Eq(t, tc.expectedJobs, gotJobs, + must.Sprintf("got unexpected list of jobs:\n%s", + gotStdout)) + } else { + test.StrContains(t, ui.ErrorWriter.String(), strings.TrimSpace(tc.expectedErr)) + } + }) + } +} diff --git a/website/content/docs/commands/node-pool/index.mdx b/website/content/docs/commands/node-pool/index.mdx index 14a28d4fc..dd849328b 100644 --- a/website/content/docs/commands/node-pool/index.mdx +++ b/website/content/docs/commands/node-pool/index.mdx @@ -22,9 +22,12 @@ following subcommands are available: - [`node pool info`][info] - Fetch information on an existing node pool. +- [`node pool jobs`][jobs] - Retrieve a list of jobs in a node pool. + - [`node pool list`][list] - Retrieve a list of node pools. [apply]: /nomad/docs/commands/node-pool/apply [delete]: /nomad/docs/commands/node-pool/delete [info]: /nomad/docs/commands/node-pool/info +[jobs]: /nomad/docs/commands/node-pool/jobs [list]: /nomad/docs/commands/node-pool/list diff --git a/website/content/docs/commands/node-pool/jobs.mdx b/website/content/docs/commands/node-pool/jobs.mdx new file mode 100644 index 000000000..f55c9723d --- /dev/null +++ b/website/content/docs/commands/node-pool/jobs.mdx @@ -0,0 +1,79 @@ +--- +layout: docs +page_title: 'Commands: node pool jobs' +description: | + The node pool jobs command is used to list jobs in node pool. +--- + +# Command: node pool jobs + +The `node pool jobs` command is used to list jobs in a node pool. + +## Usage + +```plaintext +nomad node pool jobs [options] +``` + +If ACLs are enabled, this command requires a token with the `read` capability in +a `node_pool` policy that matches the node pool being targeted. The results will +be filtered by the namespaces where the token has `read-job` capability. + +## General Options + +@include 'general_options.mdx' + +## Jobs Options + +- `-filter`: Specifies an expression used to [filter results][api_filtering]. + +- `-json`: Output the jobs in JSON format. + +- `-page-token`: Where to start [pagination][api_pagination]. + +- `-per-page`: How many results to show per page. If not specified, or set to + `0`, all results are returned. + +- `-t`: Format and display jobs using a Go template. + +## Examples + +List jobs in a specific namespace in the `prod` node pool: + +```shell-session +$ nomad node pool jobs -namespace default prod +ID Type Priority Status Submit Date +job1 service 80 running 07/25/17 15:47:11 UTC +job2 batch 40 complete 07/24/17 19:22:11 UTC +job3 service 50 dead (stopped) 07/22/17 16:34:48 UTC +``` + +List jobs in all namespaces in the `prod` node pool: + +```shell-session +$ nomad node pool jobs -namespace '*' prod +ID Namespace Type Priority Status Submit Date +job1 default service 80 running 07/25/17 15:47:11 UTC +job2 default batch 40 complete 07/24/17 19:22:11 UTC +job3 system service 50 dead (stopped) 07/22/17 16:34:48 UTC +``` + +Paginate list: + +```shell-session +$ nomad node pool jobs -per-page 2 prod +ID Type Priority Status Submit Date +job1 service 80 running 07/25/17 15:47:11 UTC +job2 batch 40 complete 07/24/17 19:22:11 UTC + +Results have been paginated. To get the next page run: + +nomad node pool jobs -per-page 2 -page-token job3 prod + +$ nomad node pool jobs -per-page 2 -page-token job3 prod +ID Type Priority Status Submit Date +job3 service 50 dead (stopped) 07/22/17 16:34:48 UTC +``` + +[api_filtering]: /nomad/api-docs#filtering +[api_pagination]: /nomad/api-docs#pagination diff --git a/website/content/partials/general_options.mdx b/website/content/partials/general_options.mdx index bb9ece387..577dd6e5c 100644 --- a/website/content/partials/general_options.mdx +++ b/website/content/partials/general_options.mdx @@ -7,8 +7,8 @@ - `-namespace=`: The target namespace for queries and actions bound to a namespace. Overrides the `NOMAD_NAMESPACE` environment variable if set. - If set to `'*'`, job and alloc subcommands query all namespaces authorized to - user. Defaults to the "default" namespace. + If set to `'*'`, subcommands which support this functionality query all + namespaces authorized to user. Defaults to the "default" namespace. - `-no-color`: Disables colored command output. Alternatively, `NOMAD_CLI_NO_COLOR` may be set. This option takes precedence over diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 4ef8ae9e8..2fe540a61 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -691,6 +691,10 @@ "title": "info", "path": "commands/node-pool/info" }, + { + "title": "jobs", + "path": "commands/node-pool/jobs" + }, { "title": "list", "path": "commands/node-pool/list"