From 07c670fdc0210536ebd16c07c5b6047aff60b4dd Mon Sep 17 00:00:00 2001 From: Alessandro De Blasis Date: Fri, 5 Nov 2021 13:02:46 +0000 Subject: [PATCH] cli: show `host_network` in `nomad status` (#11432) Enhance the CLI in order to return the host network in two flavors (default, verbose) of the `node status` command. Fixes: #11223. Signed-off-by: Alessandro De Blasis --- .changelog/11432.txt | 3 + api/nodes.go | 9 +++ client/client.go | 8 ++ command/node_status.go | 34 ++++++++ command/status_test.go | 123 +++++++++++++++++++++++++++++ nomad/structs/network.go | 10 +++ nomad/structs/structs.go | 19 +++++ website/content/api-docs/nodes.mdx | 7 ++ 8 files changed, 213 insertions(+) create mode 100644 .changelog/11432.txt diff --git a/.changelog/11432.txt b/.changelog/11432.txt new file mode 100644 index 000000000..7c1ab3f6f --- /dev/null +++ b/.changelog/11432.txt @@ -0,0 +1,3 @@ +```release-note:improvement +cli: the command `node status` now returns `host_network` information as well +``` diff --git a/api/nodes.go b/api/nodes.go index 070eae975..488f5eb62 100644 --- a/api/nodes.go +++ b/api/nodes.go @@ -506,6 +506,14 @@ type HostVolumeInfo struct { ReadOnly bool } +//HostNetworkInfo is used to return metadata about a given HostNetwork +type HostNetworkInfo struct { + Name string + CIDR string + Interface string + ReservedPorts string +} + type DrainStatus string // DrainMetadata contains information about the most recent drain operation for a given Node. @@ -541,6 +549,7 @@ type Node struct { Events []*NodeEvent Drivers map[string]*DriverInfo HostVolumes map[string]*HostVolumeInfo + HostNetworks map[string]*HostNetworkInfo CSIControllerPlugins map[string]*CSIInfo CSINodePlugins map[string]*CSIInfo LastDrain *DrainMetadata diff --git a/client/client.go b/client/client.go index 038ce31e6..f2e8a820e 100644 --- a/client/client.go +++ b/client/client.go @@ -1426,6 +1426,14 @@ func (c *Client) setupNode() error { } } } + if node.HostNetworks == nil { + if l := len(c.config.HostNetworks); l != 0 { + node.HostNetworks = make(map[string]*structs.ClientHostNetworkConfig, l) + for k, v := range c.config.HostNetworks { + node.HostNetworks[k] = v.Copy() + } + } + } if node.Name == "" { node.Name = node.ID diff --git a/command/node_status.go b/command/node_status.go index b4cef6926..c12b0cb9a 100644 --- a/command/node_status.go +++ b/command/node_status.go @@ -349,6 +349,16 @@ func nodeVolumeNames(n *api.Node) []string { return volumes } +func nodeNetworkNames(n *api.Node) []string { + var networks []string + for name := range n.HostNetworks { + networks = append(networks, name) + } + + sort.Strings(networks) + return networks +} + func formatDrain(n *api.Node) string { if n.DrainStrategy != nil { b := new(strings.Builder) @@ -400,6 +410,7 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int { if c.short { basic = append(basic, fmt.Sprintf("Host Volumes|%s", strings.Join(nodeVolumeNames(node), ","))) + basic = append(basic, fmt.Sprintf("Host Networks|%s", strings.Join(nodeNetworkNames(node), ","))) basic = append(basic, fmt.Sprintf("CSI Volumes|%s", strings.Join(nodeCSIVolumeNames(node, runningAllocs), ","))) basic = append(basic, fmt.Sprintf("Drivers|%s", strings.Join(nodeDrivers(node), ","))) c.Ui.Output(c.Colorize().Color(formatKV(basic))) @@ -428,6 +439,7 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int { // driver info in the basic output if !c.verbose { basic = append(basic, fmt.Sprintf("Host Volumes|%s", strings.Join(nodeVolumeNames(node), ","))) + basic = append(basic, fmt.Sprintf("Host Networks|%s", strings.Join(nodeNetworkNames(node), ","))) basic = append(basic, fmt.Sprintf("CSI Volumes|%s", strings.Join(nodeCSIVolumeNames(node, runningAllocs), ","))) driverStatus := fmt.Sprintf("Driver Status| %s", c.outputTruncatedNodeDriverInfo(node)) basic = append(basic, driverStatus) @@ -439,6 +451,7 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int { // If we're running in verbose mode, include full host volume and driver info if c.verbose { c.outputNodeVolumeInfo(node) + c.outputNodeNetworkInfo(node) c.outputNodeCSIVolumeInfo(client, node, runningAllocs) c.outputNodeDriverInfo(node) } @@ -544,6 +557,27 @@ func (c *NodeStatusCommand) outputNodeVolumeInfo(node *api.Node) { } } +func (c *NodeStatusCommand) outputNodeNetworkInfo(node *api.Node) { + + names := make([]string, 0, len(node.HostNetworks)) + for name := range node.HostNetworks { + names = append(names, name) + } + sort.Strings(names) + + output := make([]string, 0, len(names)+1) + output = append(output, "Name|CIDR|Interface|ReservedPorts") + + if len(names) > 0 { + c.Ui.Output(c.Colorize().Color("\n[bold]Host Networks")) + for _, hostNetworkName := range names { + info := node.HostNetworks[hostNetworkName] + output = append(output, fmt.Sprintf("%s|%v|%s|%s", hostNetworkName, info.CIDR, info.Interface, info.ReservedPorts)) + } + c.Ui.Output(formatList(output)) + } +} + func (c *NodeStatusCommand) outputNodeCSIVolumeInfo(client *api.Client, node *api.Node, runningAllocs []*api.Allocation) { // Duplicate nodeCSIVolumeNames to sort by name but also index volume names to ids diff --git a/command/status_test.go b/command/status_test.go index 810bec86e..4b3ec1a66 100644 --- a/command/status_test.go +++ b/command/status_test.go @@ -2,6 +2,7 @@ package command import ( "fmt" + "regexp" "testing" "github.com/hashicorp/nomad/command/agent" @@ -11,6 +12,7 @@ import ( "github.com/mitchellh/cli" "github.com/posener/complete" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestStatusCommand_Run_JobStatus(t *testing.T) { @@ -233,3 +235,124 @@ func TestStatusCommand_AutocompleteArgs(t *testing.T) { res := predictor.Predict(args) assert.Contains(res, job.ID) } + +func TestStatusCommand_Run_HostNetwork(t *testing.T) { + t.Parallel() + + ui := cli.NewMockUi() + + testCases := []struct { + name string + clientHostNetworks []*structs.ClientHostNetworkConfig + verbose bool + assertions func(string) + }{ + { + name: "short", + clientHostNetworks: []*structs.ClientHostNetworkConfig{{ + Name: "internal", + CIDR: "127.0.0.1/8", + Interface: "lo", + }}, + verbose: false, + assertions: func(out string) { + hostNetworksRegexpStr := `Host Networks\s+=\s+internal\n` + require.Regexp(t, regexp.MustCompile(hostNetworksRegexpStr), out) + }, + }, + { + name: "verbose", + clientHostNetworks: []*structs.ClientHostNetworkConfig{{ + Name: "internal", + CIDR: "127.0.0.1/8", + Interface: "lo", + }}, + verbose: true, + assertions: func(out string) { + verboseHostNetworksHeadRegexpStr := `Name\s+CIDR\s+Interface\s+ReservedPorts\n` + require.Regexp(t, regexp.MustCompile(verboseHostNetworksHeadRegexpStr), out) + + verboseHostNetworksBodyRegexpStr := `internal\s+127\.0\.0\.1/8\s+lo\s+\n` + require.Regexp(t, regexp.MustCompile(verboseHostNetworksBodyRegexpStr), out) + }, + }, + { + name: "verbose_nointerface", + clientHostNetworks: []*structs.ClientHostNetworkConfig{{ + Name: "public", + CIDR: "10.199.0.200/24", + }}, + verbose: true, + assertions: func(out string) { + verboseHostNetworksHeadRegexpStr := `Name\s+CIDR\s+Interface\s+ReservedPorts\n` + require.Regexp(t, regexp.MustCompile(verboseHostNetworksHeadRegexpStr), out) + + verboseHostNetworksBodyRegexpStr := `public\s+10\.199\.0\.200/24\s+\s+\n` + require.Regexp(t, regexp.MustCompile(verboseHostNetworksBodyRegexpStr), out) + }, + }, + { + name: "verbose_nointerface_with_reservedports", + clientHostNetworks: []*structs.ClientHostNetworkConfig{{ + Name: "public", + CIDR: "10.199.0.200/24", + ReservedPorts: "8080,8081", + }}, + verbose: true, + assertions: func(out string) { + verboseHostNetworksHeadRegexpStr := `Name\s+CIDR\s+Interface\s+ReservedPorts\n` + require.Regexp(t, regexp.MustCompile(verboseHostNetworksHeadRegexpStr), out) + + verboseHostNetworksBodyRegexpStr := `public\s+10\.199\.0\.200/24\s+\s+8080,8081\n` + require.Regexp(t, regexp.MustCompile(verboseHostNetworksBodyRegexpStr), out) + }, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + + // Start in dev mode so we get a node registration + srv, client, url := testServer(t, true, func(c *agent.Config) { + c.Client.HostNetworks = tt.clientHostNetworks + }) + defer srv.Shutdown() + + cmd := &StatusCommand{Meta: Meta{Ui: ui, flagAddress: url}} + + // Wait for a node to appear + var nodeID string + 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") + } + nodeID = nodes[0].ID + return true, nil + }, func(err error) { + t.Fatalf("err: %s", err) + }) + + // Query to check the node status + args := []string{"-address=" + url} + if tt.verbose { + args = append(args, "-verbose") + } + args = append(args, nodeID) + + if code := cmd.Run(args); code != 0 { + t.Fatalf("expected exit 0, got: %d", code) + } + + out := ui.OutputWriter.String() + + tt.assertions(out) + + ui.OutputWriter.Reset() + }) + } + +} diff --git a/nomad/structs/network.go b/nomad/structs/network.go index 14dbfa964..76de50bfc 100644 --- a/nomad/structs/network.go +++ b/nomad/structs/network.go @@ -616,3 +616,13 @@ type ClientHostNetworkConfig struct { Interface string `hcl:"interface"` ReservedPorts string `hcl:"reserved_ports"` } + +func (p *ClientHostNetworkConfig) Copy() *ClientHostNetworkConfig { + if p == nil { + return nil + } + + c := new(ClientHostNetworkConfig) + *c = *p + return c +} diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index 94b31e1ee..1c3ed824c 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -1908,6 +1908,9 @@ type Node struct { // HostVolumes is a map of host volume names to their configuration HostVolumes map[string]*ClientHostVolumeConfig + // HostNetworks is a map of host host_network names to their configuration + HostNetworks map[string]*ClientHostNetworkConfig + // LastDrain contains metadata about the most recent drain operation LastDrain *DrainMetadata @@ -1993,6 +1996,7 @@ func (n *Node) Copy() *Node { nn.CSINodePlugins = copyNodeCSI(nn.CSINodePlugins) nn.Drivers = copyNodeDrivers(n.Drivers) nn.HostVolumes = copyNodeHostVolumes(n.HostVolumes) + nn.HostNetworks = copyNodeHostNetworks(n.HostNetworks) return nn } @@ -2054,6 +2058,21 @@ func copyNodeHostVolumes(volumes map[string]*ClientHostVolumeConfig) map[string] return c } +// copyNodeHostVolumes is a helper to copy a map of string to HostNetwork +func copyNodeHostNetworks(networks map[string]*ClientHostNetworkConfig) map[string]*ClientHostNetworkConfig { + l := len(networks) + if l == 0 { + return nil + } + + c := make(map[string]*ClientHostNetworkConfig, l) + for network, v := range networks { + c[network] = v.Copy() + } + + return c +} + // TerminalStatus returns if the current status is terminal and // will no longer transition. func (n *Node) TerminalStatus() bool { diff --git a/website/content/api-docs/nodes.mdx b/website/content/api-docs/nodes.mdx index eeb95086e..7c234b623 100644 --- a/website/content/api-docs/nodes.mdx +++ b/website/content/api-docs/nodes.mdx @@ -296,6 +296,13 @@ $ curl \ "ReadOnly": false } }, + "HostNetworks" : { + "public": { + "Name": "public", + "CIDR": "10.199.0.200/24", + "ReservedPorts": "8080,8081" + } + } "ID": "1ac61e33-a465-2ace-f63f-cffa1285e7eb", "LastDrain": { "AccessorID": "4e1b7ce1-f8aa-d7ff-09f1-55c3a0fd3988",