node pools: implement CLI (#17388)

This commit is contained in:
Luiz Aoqui
2023-06-02 15:49:57 -04:00
committed by GitHub
parent e41b99b6d3
commit c09ca1e765
21 changed files with 2057 additions and 3 deletions

View File

@@ -616,6 +616,31 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
Meta: meta,
}, nil
},
"node pool": func() (cli.Command, error) {
return &NodePoolCommand{
Meta: meta,
}, nil
},
"node pool apply": func() (cli.Command, error) {
return &NodePoolApplyCommand{
Meta: meta,
}, nil
},
"node pool delete": func() (cli.Command, error) {
return &NodePoolDeleteCommand{
Meta: meta,
}, nil
},
"node pool info": func() (cli.Command, error) {
return &NodePoolInfoCommand{
Meta: meta,
}, nil
},
"node pool list": func() (cli.Command, error) {
return &NodePoolListCommand{
Meta: meta,
}, nil
},
"operator": func() (cli.Command, error) {
return &OperatorCommand{
Meta: meta,

101
command/node_pool.go Normal file
View File

@@ -0,0 +1,101 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package command
import (
"fmt"
"strings"
"github.com/hashicorp/go-set"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/api/contexts"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
type NodePoolCommand struct {
Meta
}
func (c *NodePoolCommand) Name() string {
return "node pool"
}
func (c *NodePoolCommand) Synopsis() string {
return "Interact with node pools"
}
func (c *NodePoolCommand) Help() string {
helpText := `
Usage: nomad node pool <subcommand> [options] [args]
This command groups subcommands for interacting with node pools. Node pools
are used to partition and control access to a group of nodes. This command
can be used to create, update, list, and delete node pools.
Create or update a node pool:
$ nomad node pool apply <path>
List all node pools:
$ nomad node pool list
Fetch information on an existing node pool:
$ nomad node info <name>
Delete a node pool:
$ nomad node pool delete <name>
Please refer to individual subcommand help for detailed usage information.
`
return strings.TrimSpace(helpText)
}
func (c *NodePoolCommand) Run(args []string) int {
return cli.RunResultHelp
}
func formatNodePoolList(pools []*api.NodePool) string {
out := make([]string, len(pools)+1)
out[0] = "Name|Description"
for i, p := range pools {
out[i+1] = fmt.Sprintf("%s|%s",
p.Name,
p.Description,
)
}
return formatList(out)
}
func nodePoolPredictor(factory ApiClientFactory, filter *set.Set[string]) complete.Predictor {
return complete.PredictFunc(func(a complete.Args) []string {
client, err := factory()
if err != nil {
return nil
}
resp, _, err := client.Search().PrefixSearch(a.Last, contexts.NodePools, nil)
if err != nil {
return nil
}
results := resp.Matches[contexts.NodePools]
if filter == nil {
return results
}
filtered := []string{}
for _, pool := range resp.Matches[contexts.NodePools] {
if filter.Contains(pool) {
continue
}
filtered = append(filtered, pool)
}
return filtered
})
}

139
command/node_pool_apply.go Normal file
View File

@@ -0,0 +1,139 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package command
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/hashicorp/hcl/v2/hclsimple"
"github.com/hashicorp/nomad/api"
"github.com/posener/complete"
)
type NodePoolApplyCommand struct {
Meta
}
func (c *NodePoolApplyCommand) Name() string {
return "node pool apply"
}
func (c *NodePoolApplyCommand) Synopsis() string {
return "Create or update a node pool"
}
func (c *NodePoolApplyCommand) Help() string {
helpText := `
Usage: nomad node pool apply [options] <input>
Apply is used to create or update a node pool. The specification file is read
from stdin by specifying "-", otherwise a path to the file is expected.
If ACLs are enabled, this command requires a token with the 'write'
capability in a 'node_pool' policy that matches the node pool being targeted.
General Options:
` + generalOptionsUsage(usageOptsDefault) + `
Apply Options:
-json
Parse the input as a JSON node pool specification.
`
return strings.TrimSpace(helpText)
}
func (c *NodePoolApplyCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-json": complete.PredictNothing,
})
}
func (c *NodePoolApplyCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictOr(
complete.PredictFiles("*.hcl"),
complete.PredictFiles("*.json"),
)
}
func (c *NodePoolApplyCommand) Run(args []string) int {
var jsonInput bool
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&jsonInput, "json", false, "")
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: <input>")
c.Ui.Error(commandErrorText(c))
return 1
}
// Read input content.
path := args[0]
var content []byte
var err error
switch path {
case "-":
content, err = io.ReadAll(os.Stdin)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to read stdin: %v", err))
return 1
}
// Set .hcl extension so the decoder doesn't fail.
if !jsonInput {
path = "stdin.nomad.hcl"
}
default:
content, err = os.ReadFile(path)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to read file %q: %v", path, err))
return 1
}
}
// Parse input.
var poolSpec nodePoolSpec
if jsonInput {
err = json.Unmarshal(content, &poolSpec.NodePool)
} else {
err = hclsimple.Decode(path, content, nil, &poolSpec)
}
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to parse input content: %v", err))
return 1
}
// Make API request.
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
_, err = client.NodePools().Register(poolSpec.NodePool, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error applying node pool: %s", err))
return 1
}
c.Ui.Output(fmt.Sprintf("Successfully applied node pool %q!", poolSpec.NodePool.Name))
return 0
}
type nodePoolSpec struct {
NodePool *api.NodePool `hcl:"node_pool,block"`
}

View File

@@ -0,0 +1,206 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package command
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/hashicorp/nomad/ci"
"github.com/mitchellh/cli"
"github.com/shoenig/test"
"github.com/shoenig/test/must"
)
func TestNodePoolApplyCommand_Implements(t *testing.T) {
ci.Parallel(t)
var _ cli.Command = &NodePoolApplyCommand{}
}
func TestNodePoolApplyCommand_Run(t *testing.T) {
ci.Parallel(t)
// Start test server.
srv, client, url := testServer(t, true, nil)
defer srv.Shutdown()
waitForNodes(t, client)
// Initialize UI and command.
ui := cli.NewMockUi()
cmd := &NodePoolApplyCommand{Meta: Meta{Ui: ui}}
// Create node pool with HCL file.
hclTestFile := `
node_pool "dev" {
description = "dev node pool"
}`
file, err := os.CreateTemp(t.TempDir(), "node-pool-test-*.hcl")
must.NoError(t, err)
_, err = file.WriteString(hclTestFile)
must.NoError(t, err)
// Run command.
args := []string{"-address", url, file.Name()}
code := cmd.Run(args)
must.Eq(t, 0, code)
// Verify node pool was created.
got, err := srv.Agent.Server().State().NodePoolByName(nil, "dev")
must.NoError(t, err)
must.NotNil(t, got)
// Update node pool.
file.Truncate(0)
file.Seek(0, 0)
hclTestFile = `
node_pool "dev" {
description = "dev node pool"
meta {
test = "true"
}
}`
_, err = file.WriteString(hclTestFile)
must.NoError(t, err)
// Run command.
code = cmd.Run(args)
must.Eq(t, 0, code)
// Verify node pool was updated.
got, err = srv.Agent.Server().State().NodePoolByName(nil, "dev")
must.NoError(t, err)
must.NotNil(t, got)
must.NotNil(t, got.Meta)
must.Eq(t, "true", got.Meta["test"])
// Create node pool with JSON file.
jsonTestFile := `
{
"Name": "prod",
"Description": "prod node pool"
}`
file, err = os.CreateTemp(t.TempDir(), "node-pool-test-*.json")
must.NoError(t, err)
_, err = file.WriteString(jsonTestFile)
must.NoError(t, err)
// Run command.
args = []string{"-address", url, "-json", file.Name()}
code = cmd.Run(args)
must.Eq(t, 0, code)
// Verify node pool was created.
got, err = srv.Agent.Server().State().NodePoolByName(nil, "prod")
must.NoError(t, err)
must.NotNil(t, got)
}
func TestNodePoolApplyCommand_Run_fail(t *testing.T) {
ci.Parallel(t)
// Start test server.
srv, client, url := testServer(t, true, nil)
defer srv.Shutdown()
waitForNodes(t, client)
testCases := []struct {
name string
args []string
input string
expectedOutput string
expectedCode int
}{
{
name: "missing file",
args: []string{},
expectedOutput: "This command takes one argument",
expectedCode: 1,
},
{
name: "file doesn't exist",
args: []string{"doesn-exist.hcl"},
expectedOutput: "no such file",
expectedCode: 1,
},
{
name: "invalid json",
args: []string{"-json", "invalid.json"},
input: "not json",
expectedOutput: "Failed to parse input",
expectedCode: 1,
},
{
name: "invalid hcl",
args: []string{"invalid.hcl"},
input: "not HCL",
expectedOutput: "Failed to parse input",
expectedCode: 1,
},
{
name: "valid json without json flag",
args: []string{"valid.json"},
input: `{"Name": "dev"}`,
expectedOutput: "Failed to parse input",
expectedCode: 1,
},
{
name: "valid hcl with json flag",
args: []string{"-json", "valid.hcl"},
input: `node_pool "dev" {}`,
expectedOutput: "Failed to parse input",
expectedCode: 1,
},
{
name: "invalid node pool hcl",
args: []string{"invalid.hcl"},
input: `not_a_node_pool "dev" {}`,
expectedOutput: "Failed to parse input",
expectedCode: 1,
},
{
name: "invalid node pool",
args: []string{"-address", url, "invalid_node_pool.hcl"},
input: `node_pool "invalid name" {}`,
expectedOutput: "Error applying node pool",
expectedCode: 1,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Initialize UI and command.
ui := cli.NewMockUi()
cmd := &NodePoolApplyCommand{Meta: Meta{Ui: ui}}
// Write input to file.
if tc.input != "" {
// Split the filename and extension from the last argument to
// add a "*" between them so os.CreateTemp retains the file
// extension.
filename := tc.args[len(tc.args)-1]
ext := filepath.Ext(filename)
name, _ := strings.CutSuffix(filename, ext)
file, err := os.CreateTemp(t.TempDir(), fmt.Sprintf("%s-*%s", name, ext))
must.NoError(t, err)
_, err = file.WriteString(tc.input)
must.NoError(t, err)
// Update last arg with full test file path.
tc.args[len(tc.args)-1] = file.Name()
}
got := cmd.Run(tc.args)
test.Eq(t, tc.expectedCode, got)
test.StrContains(t, ui.ErrorWriter.String(), tc.expectedOutput)
})
}
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package command
import (
"fmt"
"strings"
"github.com/hashicorp/go-set"
"github.com/hashicorp/nomad/api"
"github.com/posener/complete"
)
type NodePoolDeleteCommand struct {
Meta
}
func (c *NodePoolDeleteCommand) Name() string {
return "node pool delete"
}
func (c *NodePoolDeleteCommand) Synopsis() string {
return "Delete a node pool"
}
func (c *NodePoolDeleteCommand) Help() string {
helpText := `
Usage: nomad node pool delete [options] <node-pool>
Delete is used to remove a node pool.
If ACLs are enabled, this command requires a token with the 'delete'
capability in a 'node_pool' policy that matches the node pool being targeted.
General Options:
` + generalOptionsUsage(usageOptsDefault)
return strings.TrimSpace(helpText)
}
func (c *NodePoolDeleteCommand) AutocompleteFlags() complete.Flags {
return c.Meta.AutocompleteFlags(FlagSetClient)
}
func (c *NodePoolDeleteCommand) AutocompleteArgs() complete.Predictor {
return nodePoolPredictor(c.Client, set.From([]string{
api.NodePoolAll,
api.NodePoolDefault,
}))
}
func (c *NodePoolDeleteCommand) Run(args []string) int {
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
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: <node-pool>")
c.Ui.Error(commandErrorText(c))
return 1
}
pool := args[0]
// Make API equest.
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
_, err = client.NodePools().Delete(pool, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error deleting node pool: %s", err))
return 1
}
c.Ui.Output(fmt.Sprintf("Successfully deleted node pool %q!", pool))
return 0
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package command
import (
"testing"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
"github.com/mitchellh/cli"
"github.com/shoenig/test/must"
)
func TestNodePoolDeleteCommand_Implements(t *testing.T) {
ci.Parallel(t)
var _ cli.Command = &NodePoolDeleteCommand{}
}
func TestNodePoolDeleteCommand_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 pools.
dev1 := &api.NodePool{Name: "dev-1"}
_, err := client.NodePools().Register(dev1, nil)
must.NoError(t, err)
// Initialize UI and command.
ui := cli.NewMockUi()
cmd := &NodePoolDeleteCommand{Meta: Meta{Ui: ui}}
// Delete test node pool.
args := []string{"-address", url, dev1.Name}
code := cmd.Run(args)
must.Eq(t, 0, code)
must.StrContains(t, ui.OutputWriter.String(), "Successfully deleted")
// Verify node pool was delete.
got, _, err := client.NodePools().Info(dev1.Name, nil)
must.ErrorContains(t, err, "404")
must.Nil(t, got)
}
func TestNodePoolDeleteCommand_Run_fail(t *testing.T) {
ci.Parallel(t)
// Start test server.
srv, client, url := testServer(t, true, nil)
defer srv.Shutdown()
waitForNodes(t, client)
testCases := []struct {
name string
args []string
expectedErr string
expectedCode int
}{
{
name: "missing pool",
args: []string{"-address", url},
expectedCode: 1,
expectedErr: "This command takes one argument",
},
{
name: "invalid pool",
args: []string{"-address", url, "invalid"},
expectedCode: 1,
expectedErr: "not found",
},
{
name: "built-in pool",
args: []string{"-address", url, "all"},
expectedCode: 1,
expectedErr: "not allowed",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Initialize UI and command.
ui := cli.NewMockUi()
cmd := &NodePoolDeleteCommand{Meta: Meta{Ui: ui}}
// Run command.
code := cmd.Run(tc.args)
must.Eq(t, tc.expectedCode, code)
must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr)
})
}
}

167
command/node_pool_info.go Normal file
View File

@@ -0,0 +1,167 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package command
import (
"fmt"
"sort"
"strings"
"github.com/hashicorp/nomad/api"
"github.com/posener/complete"
)
type NodePoolInfoCommand struct {
Meta
}
func (c *NodePoolInfoCommand) Name() string {
return "node pool info"
}
func (c *NodePoolInfoCommand) Synopsis() string {
return "Fetch information about an existing node pool"
}
func (c *NodePoolInfoCommand) Help() string {
helpText := `
Usage: nomad node pool info <node-pool>
Info is used to fetch information about an existing 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.
General Options:
` + generalOptionsUsage(usageOptsDefault) + `
Info Options:
-json
Output the node pool in its JSON format.
-t
Format and display node pool using a Go template.
`
return strings.TrimSpace(helpText)
}
func (c *NodePoolInfoCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
})
}
func (c *NodePoolInfoCommand) AutocompleteArgs() complete.Predictor {
return nodePoolPredictor(c.Client, nil)
}
func (c *NodePoolInfoCommand) Run(args []string) int {
var json bool
var tmpl string
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&json, "json", false, "")
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: <node-pool>")
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 := c.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
}
// Format output if requested.
if json || tmpl != "" {
out, err := Format(json, tmpl, pool)
if err != nil {
c.Ui.Error(err.Error())
return 1
}
c.Ui.Output(out)
return 0
}
// Print node pool information.
basic := []string{
fmt.Sprintf("Name|%s", pool.Name),
fmt.Sprintf("Description|%s", pool.Description),
}
c.Ui.Output(formatKV(basic))
c.Ui.Output(c.Colorize().Color("\n[bold]Metadata[reset]"))
if len(pool.Meta) > 0 {
var meta []string
for k, v := range pool.Meta {
meta = append(meta, fmt.Sprintf("%s|%s", k, v))
}
sort.Strings(meta)
c.Ui.Output(formatKV(meta))
} else {
c.Ui.Output("No metadata")
}
c.Ui.Output(c.Colorize().Color("\n[bold]Scheduler Configuration[reset]"))
if pool.SchedulerConfiguration != nil {
schedConfig := []string{
fmt.Sprintf("Scheduler Algorithm|%s", pool.SchedulerConfiguration.SchedulerAlgorithm),
}
c.Ui.Output(formatKV(schedConfig))
} else {
c.Ui.Output("No scheduler configuration")
}
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
}
}

View File

@@ -0,0 +1,172 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package command
import (
"strings"
"testing"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
"github.com/mitchellh/cli"
"github.com/shoenig/test"
"github.com/shoenig/test/must"
)
func TestNodePoolInfoCommand_Implements(t *testing.T) {
ci.Parallel(t)
var _ cli.Command = &NodePoolInfoCommand{}
}
func TestNodePoolInfoCommand_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 pools.
dev1 := &api.NodePool{
Name: "dev-1",
Description: "Test pool",
Meta: map[string]string{
"env": "test",
},
SchedulerConfiguration: &api.NodePoolSchedulerConfiguration{
SchedulerAlgorithm: api.SchedulerAlgorithmSpread,
},
}
_, err := client.NodePools().Register(dev1, nil)
must.NoError(t, err)
dev1Output := `
Name = dev-1
Description = Test pool
Metadata
env = test
Scheduler Configuration
Scheduler Algorithm = spread`
dev1JsonOutput := `
{
"Description": "Test pool",
"Meta": {
"env": "test"
},
"Name": "dev-1",
"SchedulerConfiguration": {
"SchedulerAlgorithm": "spread"
}
}`
// These two node pools are used to test exact prefix match.
prod1 := &api.NodePool{Name: "prod-1"}
_, err = client.NodePools().Register(prod1, nil)
must.NoError(t, err)
prod12 := &api.NodePool{Name: "prod-12"}
_, err = client.NodePools().Register(prod12, nil)
must.NoError(t, err)
testCases := []struct {
name string
args []string
expectedOut string
expectedErr string
expectedCode int
}{
{
name: "basic info",
args: []string{"dev-1"},
expectedOut: dev1Output,
expectedCode: 0,
},
{
name: "basic info by prefix",
args: []string{"dev"},
expectedOut: dev1Output,
expectedCode: 0,
},
{
name: "exact prefix match",
args: []string{"prod-1"},
expectedOut: `
Name = prod-1
Description = <none>
Metadata
No metadata
Scheduler Configuration
No scheduler configuration`,
expectedCode: 0,
},
{
name: "json",
args: []string{"-json", "dev"},
expectedOut: dev1JsonOutput,
expectedCode: 0,
},
{
name: "template",
args: []string{
"-t", "{{.Name}} -> {{.Meta.env}}",
"dev-1",
},
expectedOut: "dev-1 -> test",
expectedCode: 0,
},
{
name: "fail because of missing node pool arg",
args: []string{},
expectedErr: "This command takes one argument",
expectedCode: 1,
},
{
name: "fail because no match",
args: []string{"invalid"},
expectedErr: `No node pool with prefix "invalid" found`,
expectedCode: 1,
},
{
name: "fail because of multiple matches",
args: []string{"de"}, // Matches default and dev-1.
expectedErr: "Prefix matched multiple node pools",
expectedCode: 1,
},
{
name: "fail because of invalid template",
args: []string{
"-t", "{{.NotValid}}",
"dev-1",
},
expectedErr: "Error formatting the data",
expectedCode: 1,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Initialize UI and command.
ui := cli.NewMockUi()
cmd := &NodePoolInfoCommand{Meta: Meta{Ui: ui}}
// Run command.
args := []string{"-address", url}
args = append(args, tc.args...)
code := cmd.Run(args)
gotStdout := ui.OutputWriter.String()
gotStdout = jsonOutputRaftIndexes.ReplaceAllString(gotStdout, "")
test.Eq(t, tc.expectedCode, code)
test.StrContains(t, gotStdout, strings.TrimSpace(tc.expectedOut))
test.StrContains(t, ui.ErrorWriter.String(), strings.TrimSpace(tc.expectedErr))
})
}
}

141
command/node_pool_list.go Normal file
View File

@@ -0,0 +1,141 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package command
import (
"fmt"
"os"
"strings"
"github.com/hashicorp/nomad/api"
"github.com/posener/complete"
)
type NodePoolListCommand struct {
Meta
}
func (c *NodePoolListCommand) Name() string {
return "node pool list"
}
func (c *NodePoolListCommand) Synopsis() string {
return "List node pools"
}
func (c *NodePoolListCommand) Help() string {
helpText := `
Usage: nomad node pool list [options]
List is used to list existing node pools.
If ACLs are enabled, this command requires a management token to view all
node pools. A non-management token can be used to list node pools for which
the token has the 'read' capability.
General Options:
` + generalOptionsUsage(usageOptsDefault) + `
List Options:
-filter
Specifies an expression used to filter results.
-json
Output the node pools 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 the node pools using a Go template.
`
return strings.TrimSpace(helpText)
}
func (c *NodePoolListCommand) 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 *NodePoolListCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *NodePoolListCommand) Run(args []string) int {
var json bool
var perPage int
var tmpl, pageToken, filter string
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.StringVar(&filter, "filter", "", "")
flags.BoolVar(&json, "json", false, "")
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 don't have any arguments.
if len(flags.Args()) != 0 {
c.Ui.Error("This command takes no arguments")
c.Ui.Error(commandErrorText(c))
return 1
}
// Make list request.
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
opts := &api.QueryOptions{
Filter: filter,
PerPage: int32(perPage),
NextToken: pageToken,
}
pools, qm, err := client.NodePools().List(opts)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error querying node pools: %s", err))
return 1
}
// Format output if requested.
if json || tmpl != "" {
out, err := Format(json, tmpl, pools)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error formatting output: %s", err))
return 1
}
c.Ui.Output(out)
return 0
}
c.Ui.Output(formatNodePoolList(pools))
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
}

View File

@@ -0,0 +1,157 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package command
import (
"strings"
"testing"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
"github.com/mitchellh/cli"
"github.com/shoenig/test"
"github.com/shoenig/test/must"
)
func TestNodePoolListCommand_Implements(t *testing.T) {
ci.Parallel(t)
var _ cli.Command = &NodePoolListCommand{}
}
func TestNodePoolListCommand_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 pools.
dev1 := &api.NodePool{Name: "dev-1", Description: "Pool dev-1"}
_, err := client.NodePools().Register(dev1, nil)
must.NoError(t, err)
prod1 := &api.NodePool{Name: "prod-1"}
_, err = client.NodePools().Register(prod1, nil)
must.NoError(t, err)
prod2 := &api.NodePool{Name: "prod-2", Description: "Pool prod-2"}
_, err = client.NodePools().Register(prod2, nil)
must.NoError(t, err)
testCases := []struct {
name string
args []string
expectedOut string
expectedErr string
expectedCode int
}{
{
name: "list all",
args: []string{},
expectedOut: `
Name Description
all Node pool with all nodes in the cluster.
default Default node pool.
dev-1 Pool dev-1
prod-1 <none>
prod-2 Pool prod-2`,
expectedCode: 0,
},
{
name: "filter",
args: []string{
"-filter", `Name contains "prod"`,
},
expectedOut: `
Name Description
prod-1 <none>
prod-2 Pool prod-2`,
expectedCode: 0,
},
{
name: "paginate",
args: []string{
"-per-page", "2",
},
expectedOut: `
Name Description
all Node pool with all nodes in the cluster.
default Default node pool.`,
expectedCode: 0,
},
{
name: "paginate page 2",
args: []string{
"-per-page", "2",
"-page-token", "dev-1",
},
expectedOut: `
Name Description
dev-1 Pool dev-1
prod-1 <none>`,
expectedCode: 0,
},
{
name: "json",
args: []string{
"-json",
"-filter", `Name == "prod-1"`,
},
expectedOut: `
[
{
"Description": "",
"Meta": null,
"Name": "prod-1",
"SchedulerConfiguration": null
}
]`,
expectedCode: 0,
},
{
name: "template",
args: []string{
"-t", "{{range .}}{{.Name}} {{end}}",
},
expectedOut: "all default dev-1 prod-1 prod-2",
expectedCode: 0,
},
{
name: "fail because of arg",
args: []string{"invalid"},
expectedErr: "This command takes no arguments",
expectedCode: 1,
},
{
name: "fail because of invalid template",
args: []string{
"-t", "{{.NotValid}}",
},
expectedErr: "Error formatting the data",
expectedCode: 1,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Initialize UI and command.
ui := cli.NewMockUi()
cmd := &NodePoolListCommand{Meta: Meta{Ui: ui}}
// Run command.
args := []string{"-address", url}
args = append(args, tc.args...)
code := cmd.Run(args)
gotStdout := ui.OutputWriter.String()
gotStdout = jsonOutputRaftIndexes.ReplaceAllString(gotStdout, "")
test.Eq(t, tc.expectedCode, code)
test.StrContains(t, gotStdout, strings.TrimSpace(tc.expectedOut))
test.StrContains(t, ui.ErrorWriter.String(), strings.TrimSpace(tc.expectedErr))
})
}
}

89
command/node_pool_test.go Normal file
View File

@@ -0,0 +1,89 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package command
import (
"regexp"
"testing"
"github.com/hashicorp/go-set"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"github.com/shoenig/test/must"
)
var (
// jsonOutputRaftIndexes is a regex that matches raft index fields in JSON
// strings. It can be used to remove them to make test results more
// consistent.
jsonOutputRaftIndexes = regexp.MustCompile(`(?m)\s*"(?:CreateIndex|ModifyIndex)".*`)
)
func TestNodePoolCommand_Implements(t *testing.T) {
ci.Parallel(t)
var _ cli.Command = &NodePoolCommand{}
}
func TestMeta_NodePoolPredictor(t *testing.T) {
ci.Parallel(t)
// Start test server.
srv, client, url := testServer(t, true, nil)
defer srv.Shutdown()
waitForNodes(t, client)
// Register some test node pools.
dev1 := &api.NodePool{Name: "dev-1"}
_, err := client.NodePools().Register(dev1, nil)
must.NoError(t, err)
dev2 := &api.NodePool{Name: "dev-2"}
_, err = client.NodePools().Register(dev2, nil)
must.NoError(t, err)
prod := &api.NodePool{Name: "prod"}
_, err = client.NodePools().Register(prod, nil)
must.NoError(t, err)
testCases := []struct {
name string
args complete.Args
filter *set.Set[string]
expected []string
}{
{
name: "find with prefix",
args: complete.Args{
Last: "de",
},
expected: []string{"default", "dev-1", "dev-2"},
},
{
name: "filter",
args: complete.Args{
Last: "de",
},
filter: set.From([]string{"default"}),
expected: []string{"dev-1", "dev-2"},
},
{
name: "find all",
args: complete.Args{
Last: "",
},
expected: []string{"all", "default", "dev-1", "dev-2", "prod"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
m := &Meta{flagAddress: url}
got := nodePoolPredictor(m.Client, tc.filter).Predict(tc.args)
must.SliceContainsAll(t, tc.expected, got)
})
}
}