From 0991651dce2747b5fa7c9d79a9f3256391427165 Mon Sep 17 00:00:00 2001 From: Gerrit Date: Tue, 7 Mar 2023 09:08:45 +0100 Subject: [PATCH] Add `switch connected-machines` command. (#187) --- cmd/switch.go | 71 +++++++++++++- cmd/switch_test.go | 89 +++++++++++++++++ cmd/tableprinters/printer.go | 2 + cmd/tableprinters/switch.go | 108 +++++++++++++++++++++ cmd/tableprinters/switch_test.go | 92 ++++++++++++++++++ docs/metalctl_switch.md | 1 + docs/metalctl_switch_connected-machines.md | 67 +++++++++++++ 7 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 cmd/tableprinters/switch_test.go create mode 100644 docs/metalctl_switch_connected-machines.md diff --git a/cmd/switch.go b/cmd/switch.go index ece9ea9..68740b0 100644 --- a/cmd/switch.go +++ b/cmd/switch.go @@ -6,6 +6,7 @@ import ( "os/exec" "strings" + "github.com/metal-stack/metal-go/api/client/machine" "github.com/metal-stack/metal-go/api/client/switch_operations" "github.com/metal-stack/metal-go/api/models" "github.com/metal-stack/metal-lib/pkg/genericcli" @@ -81,6 +82,37 @@ func newSwitchCmd(c *config) *cobra.Command { must(switchDetailCmd.RegisterFlagCompletionFunc("partition", c.comp.PartitionListCompletion)) must(switchDetailCmd.RegisterFlagCompletionFunc("rack", c.comp.SwitchRackListCompletion)) + switchMachinesCmd := &cobra.Command{ + Use: "connected-machines", + Short: "shows switches with their connected machines", + RunE: func(cmd *cobra.Command, args []string) error { + return w.switchMachines() + }, + Example: `The command will show the machines connected to the switch ports. + +Can also be used with -o template in order to generate CSV-style output: + +$ metalctl switch connected-machines -o template --template '{{ $machines := .machines }}{{ range .switches }}{{ $switch := . }}{{ range .connections }}{{ $switch.id }},{{ $switch.rack_id }},{{ .nic.name }},{{ .machine_id }},{{ (index $machines .machine_id).ipmi.fru.product_serial }}{{ printf "\n" }}{{ end }}{{ end }}' +r01leaf01,swp1,f78cc340-e5e8-48ed-8fe7-2336c1e2ded2, +r01leaf01,swp2,44e3a522-5f48-4f3c-9188-41025f9e401e, +... +`, + } + + switchMachinesCmd.Flags().String("id", "", "ID of the switch.") + switchMachinesCmd.Flags().String("name", "", "Name of the switch.") + switchMachinesCmd.Flags().String("os-vendor", "", "OS vendor of this switch.") + switchMachinesCmd.Flags().String("os-version", "", "OS version of this switch.") + switchMachinesCmd.Flags().String("partition", "", "Partition of this switch.") + switchMachinesCmd.Flags().String("rack", "", "Rack of this switch.") + switchMachinesCmd.Flags().String("size", "", "Size of the connectedmachines.") + + must(switchMachinesCmd.RegisterFlagCompletionFunc("id", c.comp.SwitchListCompletion)) + must(switchMachinesCmd.RegisterFlagCompletionFunc("name", c.comp.SwitchNameListCompletion)) + must(switchMachinesCmd.RegisterFlagCompletionFunc("partition", c.comp.PartitionListCompletion)) + must(switchMachinesCmd.RegisterFlagCompletionFunc("rack", c.comp.SwitchRackListCompletion)) + must(switchMachinesCmd.RegisterFlagCompletionFunc("size", c.comp.SizeListCompletion)) + switchReplaceCmd := &cobra.Command{ Use: "replace ", Short: "put a leaf switch into replace mode in preparation for physical replacement. For a description of the steps involved see the long help.", @@ -120,7 +152,7 @@ Operational steps to replace a switch: ValidArgsFunction: c.comp.SwitchListCompletion, } - return genericcli.NewCmds(cmdsConfig, switchDetailCmd, switchReplaceCmd, switchSSHCmd, switchConsoleCmd) + return genericcli.NewCmds(cmdsConfig, switchDetailCmd, switchMachinesCmd, switchReplaceCmd, switchSSHCmd, switchConsoleCmd) } func (c switchCmd) Get(id string) (*models.V1SwitchResponse, error) { @@ -214,6 +246,43 @@ func (c *switchCmd) switchDetail() error { return c.listPrinter.Print(result) } +func (c *switchCmd) switchMachines() error { + switches, err := c.List() + if err != nil { + return err + } + + err = sorters.SwitchSorter().SortBy(switches) + if err != nil { + return err + } + + resp, err := c.client.Machine().FindIPMIMachines(machine.NewFindIPMIMachinesParams().WithBody(&models.V1MachineFindRequest{ + PartitionID: viper.GetString("partition"), + Rackid: viper.GetString("rack"), + Sizeid: viper.GetString("size"), + }), nil) + if err != nil { + return err + } + + machines := map[string]*models.V1MachineIPMIResponse{} + for _, m := range resp.Payload { + m := m + + if m.ID == nil { + continue + } + + machines[*m.ID] = m + } + + return c.listPrinter.Print(&tableprinters.SwitchesWithMachines{ + SS: switches, + MS: machines, + }) +} + func (c *switchCmd) switchReplace(args []string) error { id, err := genericcli.GetExactlyOneArg(args) if err != nil { diff --git a/cmd/switch_test.go b/cmd/switch_test.go index f733da9..1d386be 100644 --- a/cmd/switch_test.go +++ b/cmd/switch_test.go @@ -5,11 +5,13 @@ import ( "time" "github.com/go-openapi/strfmt" + "github.com/metal-stack/metal-go/api/client/machine" "github.com/metal-stack/metal-go/api/client/switch_operations" "github.com/metal-stack/metal-go/api/models" "github.com/metal-stack/metal-go/test/client" "github.com/metal-stack/metal-lib/pkg/pointer" "github.com/metal-stack/metal-lib/pkg/testcommon" + "github.com/metal-stack/metalctl/cmd/tableprinters" "github.com/spf13/afero" "github.com/stretchr/testify/mock" @@ -208,6 +210,93 @@ ID PARTITION RACK OS IP MODE LAST SYNC SYNC DUR } } +func Test_SwitchCmd_ConnectedMachinesResult(t *testing.T) { + tests := []*test[*tableprinters.SwitchesWithMachines]{ + { + name: "connected-machines", + cmd: func(want *tableprinters.SwitchesWithMachines) []string { + return []string{"switch", "connected-machines"} + }, + mocks: &client.MetalMockFns{ + Machine: func(mock *mock.Mock) { + mock.On("FindIPMIMachines", testcommon.MatchIgnoreContext(t, machine.NewFindIPMIMachinesParams().WithBody(&models.V1MachineFindRequest{})), nil).Return(&machine.FindIPMIMachinesOK{ + Payload: []*models.V1MachineIPMIResponse{ + { + ID: pointer.Pointer("machine-1"), + Rackid: "rack-1", + Partition: &models.V1PartitionResponse{ + ID: pointer.Pointer("1"), + }, + Size: &models.V1SizeResponse{ + ID: pointer.Pointer("n1-medium-x86"), + }, + Ipmi: &models.V1MachineIPMI{ + Fru: &models.V1MachineFru{ + ProductSerial: "123", + }, + }, + }, + }, + }, nil) + }, + SwitchOperations: func(mock *mock.Mock) { + mock.On("FindSwitches", testcommon.MatchIgnoreContext(t, switch_operations.NewFindSwitchesParams().WithBody(&models.V1SwitchFindRequest{})), nil).Return(&switch_operations.FindSwitchesOK{ + Payload: []*models.V1SwitchResponse{ + switch2, + switch1, + }, + }, nil) + }, + }, + want: &tableprinters.SwitchesWithMachines{ + SS: []*models.V1SwitchResponse{ + switch1, + switch2, + }, + MS: map[string]*models.V1MachineIPMIResponse{ + "machine-1": { + ID: pointer.Pointer("machine-1"), + Rackid: "rack-1", + Partition: &models.V1PartitionResponse{ + ID: pointer.Pointer("1"), + }, + Size: &models.V1SizeResponse{ + ID: pointer.Pointer("n1-medium-x86"), + }, + Ipmi: &models.V1MachineIPMI{ + Fru: &models.V1MachineFru{ + ProductSerial: "123", + }, + }, + }, + }, + }, + wantTable: pointer.Pointer(` +ID NIC NAME IDENTIFIER PARTITION RACK SIZE PRODUCT SERIAL +1 1 rack-1 +└─╴machine-1 a-name a-mac 1 rack-1 n1-medium-x86 123 +2 1 rack-1 +└─╴machine-1 a-name a-mac 1 rack-1 n1-medium-x86 123 +`), + wantWideTable: pointer.Pointer(` +ID NIC NAME IDENTIFIER PARTITION RACK SIZE PRODUCT SERIAL +1 1 rack-1 +└─╴machine-1 a-name a-mac 1 rack-1 n1-medium-x86 123 +2 1 rack-1 +└─╴machine-1 a-name a-mac 1 rack-1 n1-medium-x86 123 +`), + template: pointer.Pointer(`{{ $machines := .machines }}{{ range .switches }}{{ $switch := . }}{{ range .connections }}{{ $switch.id }},{{ $switch.rack_id }},{{ .nic.name }},{{ .machine_id }},{{ (index $machines .machine_id).ipmi.fru.product_serial }}{{ printf "\n" }}{{ end }}{{ end }}`), + wantTemplate: pointer.Pointer(` +1,rack-1,a-name,machine-1,123 +2,rack-1,a-name,machine-1,123 +`), + }, + } + for _, tt := range tests { + tt.testCmd(t) + } +} + func Test_SwitchCmd_SingleResult(t *testing.T) { tests := []*test[*models.V1SwitchResponse]{ { diff --git a/cmd/tableprinters/printer.go b/cmd/tableprinters/printer.go index 2c8cbd7..b178bde 100644 --- a/cmd/tableprinters/printer.go +++ b/cmd/tableprinters/printer.go @@ -55,6 +55,8 @@ func (t *TablePrinter) ToHeaderAndRows(data any, wide bool) ([]string, [][]strin return t.SwitchTable(pointer.WrapInSlice(d), wide) case []*SwitchDetail: return t.SwitchDetailTable(d, wide) + case *SwitchesWithMachines: + return t.SwitchWithConnectedMachinesTable(d, wide) case *models.V1NetworkResponse: return t.NetworkTable(pointer.WrapInSlice(d), wide) case []*models.V1NetworkResponse: diff --git a/cmd/tableprinters/switch.go b/cmd/tableprinters/switch.go index 7850ef5..561a568 100644 --- a/cmd/tableprinters/switch.go +++ b/cmd/tableprinters/switch.go @@ -2,12 +2,16 @@ package tableprinters import ( "fmt" + "regexp" + "sort" + "strconv" "strings" "time" "github.com/fatih/color" "github.com/metal-stack/metal-go/api/models" "github.com/metal-stack/metal-lib/pkg/pointer" + "github.com/spf13/viper" ) func (t *TablePrinter) SwitchTable(data []*models.V1SwitchResponse, wide bool) ([]string, [][]string, error) { @@ -88,6 +92,110 @@ func (t *TablePrinter) SwitchTable(data []*models.V1SwitchResponse, wide bool) ( return header, rows, nil } +type SwitchesWithMachines struct { + SS []*models.V1SwitchResponse `json:"switches" yaml:"switches"` + MS map[string]*models.V1MachineIPMIResponse `json:"machines" yaml:"machines"` +} + +func (t *TablePrinter) SwitchWithConnectedMachinesTable(data *SwitchesWithMachines, wide bool) ([]string, [][]string, error) { + var ( + rows [][]string + ) + + header := []string{"ID", "NIC Name", "Identifier", "Partition", "Rack", "Size", "Product Serial"} + + for _, s := range data.SS { + id := pointer.SafeDeref(s.ID) + partition := pointer.SafeDeref(pointer.SafeDeref(s.Partition).ID) + rack := pointer.SafeDeref(s.RackID) + + rows = append(rows, []string{id, "", "", partition, rack}) + + conns := s.Connections + if viper.IsSet("size") { + conns = []*models.V1SwitchConnection{} + for _, conn := range s.Connections { + conn := conn + + m, ok := data.MS[conn.MachineID] + if !ok { + continue + } + + if pointer.SafeDeref(m.Size.ID) == viper.GetString("size") { + conns = append(conns, conn) + } + } + } + + sort.Slice(conns, switchInterfaceNameLessFunc(conns)) + + for i, conn := range conns { + prefix := "├" + if i == len(conns)-1 { + prefix = "└" + } + prefix += "─╴" + + m, ok := data.MS[conn.MachineID] + if !ok { + return nil, nil, fmt.Errorf("switch port %s is connected to a machine which does not exist: %q", pointer.SafeDeref(pointer.SafeDeref(conn.Nic).Name), conn.MachineID) + } + + identifier := pointer.SafeDeref(conn.Nic.Identifier) + if identifier == "" { + identifier = pointer.SafeDeref(conn.Nic.Mac) + } + + rows = append(rows, []string{ + fmt.Sprintf("%s%s", prefix, pointer.SafeDeref(m.ID)), + pointer.SafeDeref(pointer.SafeDeref(conn.Nic).Name), + identifier, + pointer.SafeDeref(pointer.SafeDeref(m.Partition).ID), + m.Rackid, + pointer.SafeDeref(pointer.SafeDeref(m.Size).ID), + pointer.SafeDeref(pointer.SafeDeref(m.Ipmi).Fru).ProductSerial, + }) + } + } + + return header, rows, nil +} + +var numberRegex = regexp.MustCompile("([0-9]+)") + +func switchInterfaceNameLessFunc(conns []*models.V1SwitchConnection) func(i, j int) bool { + return func(i, j int) bool { + var ( + a = pointer.SafeDeref(pointer.SafeDeref(conns[i]).Nic.Name) + b = pointer.SafeDeref(pointer.SafeDeref(conns[j]).Nic.Name) + + aMatch = numberRegex.FindAllStringSubmatch(a, -1) + bMatch = numberRegex.FindAllStringSubmatch(b, -1) + ) + + for i := range aMatch { + if i >= len(bMatch) { + return true + } + + interfaceNumberA, aErr := strconv.Atoi(aMatch[i][0]) + interfaceNumberB, bErr := strconv.Atoi(bMatch[i][0]) + + if aErr == nil && bErr == nil { + if interfaceNumberA < interfaceNumberB { + return true + } + if interfaceNumberA != interfaceNumberB { + return false + } + } + } + + return a < b + } +} + type SwitchDetail struct { *models.V1SwitchResponse } diff --git a/cmd/tableprinters/switch_test.go b/cmd/tableprinters/switch_test.go new file mode 100644 index 0000000..de79d3f --- /dev/null +++ b/cmd/tableprinters/switch_test.go @@ -0,0 +1,92 @@ +package tableprinters + +import ( + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/metal-stack/metal-go/api/models" + "github.com/metal-stack/metal-lib/pkg/pointer" + "github.com/metal-stack/metal-lib/pkg/testcommon" +) + +func Test_switchInterfaceNameLessFunc(t *testing.T) { + tests := []struct { + name string + conns []*models.V1SwitchConnection + want []*models.V1SwitchConnection + }{ + { + name: "sorts interface names for cumulus-like interface names", + conns: []*models.V1SwitchConnection{ + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp10")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s4")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s3")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s1")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s2")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp9")}}, + }, + want: []*models.V1SwitchConnection{ + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s1")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s2")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s3")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s4")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp9")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp10")}}, + }, + }, + { + name: "sorts interface names for sonic-like interface names", + conns: []*models.V1SwitchConnection{ + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet3")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet49")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet10")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet2")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet1")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet11")}}, + }, + want: []*models.V1SwitchConnection{ + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet1")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet2")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet3")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet10")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet11")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet49")}}, + }, + }, + { + name: "sorts interface names edge cases", + conns: []*models.V1SwitchConnection{ + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("123")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet1")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s4w5")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("foo")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s3w3")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet100")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s4w6")}}, + {Nic: &models.V1SwitchNic{Name: nil}}, + }, + want: []*models.V1SwitchConnection{ + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s3w3")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s4w5")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("swp1s4w6")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet1")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("Ethernet100")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("")}}, + {Nic: &models.V1SwitchNic{Name: nil}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("123")}}, + {Nic: &models.V1SwitchNic{Name: pointer.Pointer("foo")}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sort.Slice(tt.conns, switchInterfaceNameLessFunc(tt.conns)) + + if diff := cmp.Diff(tt.conns, tt.want, testcommon.StrFmtDateComparer()); diff != "" { + t.Errorf("diff (+got -want):\n %s", diff) + } + }) + } +} diff --git a/docs/metalctl_switch.md b/docs/metalctl_switch.md index fbe8b5e..f941ee6 100644 --- a/docs/metalctl_switch.md +++ b/docs/metalctl_switch.md @@ -43,6 +43,7 @@ switch are the leaf switches in the data center that are controlled by metal-sta ### SEE ALSO * [metalctl](metalctl.md) - a cli to manage entities in the metal-stack api +* [metalctl switch connected-machines](metalctl_switch_connected-machines.md) - shows switches with their connected machines * [metalctl switch console](metalctl_switch_console.md) - connect to the switch console * [metalctl switch delete](metalctl_switch_delete.md) - deletes the switch * [metalctl switch describe](metalctl_switch_describe.md) - describes the switch diff --git a/docs/metalctl_switch_connected-machines.md b/docs/metalctl_switch_connected-machines.md new file mode 100644 index 0000000..8d6619f --- /dev/null +++ b/docs/metalctl_switch_connected-machines.md @@ -0,0 +1,67 @@ +## metalctl switch connected-machines + +shows switches with their connected machines + +``` +metalctl switch connected-machines [flags] +``` + +### Examples + +``` +The command will show the machines connected to the switch ports. + +Can also be used with -o template in order to generate CSV-style output: + +$ metalctl switch connected-machines -o template --template '{{ $machines := .machines }}{{ range .switches }}{{ $switch := . }}{{ range .connections }}{{ $switch.id }},{{ $switch.rack_id }},{{ .nic.name }},{{ .machine_id }},{{ (index $machines .machine_id).ipmi.fru.product_serial }}{{ printf "\n" }}{{ end }}{{ end }}' +r01leaf01,swp1,f78cc340-e5e8-48ed-8fe7-2336c1e2ded2, +r01leaf01,swp2,44e3a522-5f48-4f3c-9188-41025f9e401e, +... + +``` + +### Options + +``` + -h, --help help for connected-machines + --id string ID of the switch. + --name string Name of the switch. + --os-vendor string OS vendor of this switch. + --os-version string OS version of this switch. + --partition string Partition of this switch. + --rack string Rack of this switch. + --size string Size of the connectedmachines. +``` + +### Options inherited from parent commands + +``` + --api-token string api token to authenticate. Can be specified with METALCTL_API_TOKEN environment variable. + --api-url string api server address. Can be specified with METALCTL_API_URL environment variable. + -c, --config string alternative config file path, (default is ~/.metalctl/config.yaml). + Example config.yaml: + + --- + apitoken: "alongtoken" + ... + + + --debug debug output + --force-color force colored output even without tty + --kubeconfig string Path to the kube-config to use for authentication and authorization. Is updated by login. Uses default path if not specified. + --no-headers do not print headers of table output format (default print headers) + -o, --output-format string output format (table|wide|markdown|json|yaml|template), wide is a table with more columns. (default "table") + --template string output template for template output-format, go template format. + For property names inspect the output of -o json or -o yaml for reference. + Example for machines: + + metalctl machine list -o template --template "{{ .id }}:{{ .size.id }}" + + + --yes-i-really-mean-it skips security prompts (which can be dangerous to set blindly because actions can lead to data loss or additional costs) +``` + +### SEE ALSO + +* [metalctl switch](metalctl_switch.md) - manage switch entities +