diff --git a/cmd/admin/v2/commands.go b/cmd/admin/v2/commands.go index 881a7be..69a7691 100644 --- a/cmd/admin/v2/commands.go +++ b/cmd/admin/v2/commands.go @@ -18,6 +18,7 @@ func AddCmds(cmd *cobra.Command, c *config.Config) { adminCmd.AddCommand(newTenantCmd(c)) adminCmd.AddCommand(newTokenCmd(c)) adminCmd.AddCommand(newProjectCmd(c)) + adminCmd.AddCommand(newSwitchCmd(c)) cmd.AddCommand(adminCmd) } diff --git a/cmd/admin/v2/switch.go b/cmd/admin/v2/switch.go new file mode 100644 index 0000000..f367c68 --- /dev/null +++ b/cmd/admin/v2/switch.go @@ -0,0 +1,510 @@ +package v2 + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/metal-stack/api/go/enum" + adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/cli/cmd/config" + "github.com/metal-stack/cli/cmd/sorters" + "github.com/metal-stack/cli/cmd/tableprinters" + "github.com/metal-stack/metal-lib/pkg/genericcli" + "github.com/metal-stack/metal-lib/pkg/genericcli/printers" + "github.com/metal-stack/metal-lib/pkg/multisort" + "github.com/metal-stack/metal-lib/pkg/pointer" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type switchCmd struct { + c *config.Config +} + +func newSwitchCmd(c *config.Config) *cobra.Command { + sw := &switchCmd{ + c: c, + } + + cmdsConfig := &genericcli.CmdsConfig[any, *adminv2.SwitchServiceUpdateRequest, *apiv2.Switch]{ + GenericCLI: genericcli.NewGenericCLI(sw).WithFS(c.Fs), + OnlyCmds: genericcli.OnlyCmds( + genericcli.DescribeCmd, + genericcli.ListCmd, + genericcli.UpdateCmd, + genericcli.DeleteCmd, + genericcli.EditCmd, + ), + BinaryName: config.BinaryName, + Singular: "switch", + Plural: "switches", + Description: "view and manage network switches", + Aliases: []string{"sw"}, + DescribePrinter: func() printers.Printer { return c.DescribePrinter }, + ListPrinter: func() printers.Printer { return c.ListPrinter }, + Sorter: &multisort.Sorter[*apiv2.Switch]{}, + ListCmdMutateFn: func(cmd *cobra.Command) { + cmd.Flags().String("id", "", "ID of the switch.") + cmd.Flags().String("os-vendor", "", "OS vendor of this switch.") + cmd.Flags().String("os-version", "", "OS version of this switch.") + cmd.Flags().String("partition", "", "Partition of this switch.") + cmd.Flags().String("rack", "", "Rack of this switch.") + + genericcli.Must(cmd.RegisterFlagCompletionFunc("id", c.Completion.SwitchListCompletion)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("partition", c.Completion.PartitionListCompletion)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("rack", c.Completion.SwitchRackListCompletion)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("os-vendor", c.Completion.SwitchOSVendorListCompletion)) + genericcli.Must(cmd.RegisterFlagCompletionFunc("os-version", c.Completion.SwitchOSVersionListCompletion)) + }, + DeleteCmdMutateFn: func(cmd *cobra.Command) { + cmd.Flags().Bool("force", false, "forcefully delete the switch accepting the risk that it still has machines connected to it") + }, + } + + switchConnectedMachinesCmd := &cobra.Command{ + Use: "connected-machines", + Short: "shows switches with their connected machines", + RunE: func(cmd *cobra.Command, args []string) error { + return sw.switchConnectedMachines() + }, + Example: "The command will show the machines connected to the switch ports.", + } + + switchConnectedMachinesCmd.Flags().String("id", "", "ID of the switch.") + switchConnectedMachinesCmd.Flags().String("os-vendor", "", "OS vendor of this switch.") + switchConnectedMachinesCmd.Flags().String("os-version", "", "OS version of this switch.") + switchConnectedMachinesCmd.Flags().String("partition", "", "Partition of this switch.") + switchConnectedMachinesCmd.Flags().String("rack", "", "Rack of this switch.") + + // TODO: add once size and machine completion are implemented + // switchMachinesCmd.Flags().String("size", "", "Size of the connected machines.") + // switchMachinesCmd.Flags().String("machine-id", "", "The id of the connected machine, ignores size flag if set.") + + genericcli.Must(switchConnectedMachinesCmd.RegisterFlagCompletionFunc("id", c.Completion.SwitchListCompletion)) + genericcli.Must(switchConnectedMachinesCmd.RegisterFlagCompletionFunc("partition", c.Completion.PartitionListCompletion)) + genericcli.Must(switchConnectedMachinesCmd.RegisterFlagCompletionFunc("rack", c.Completion.SwitchRackListCompletion)) + + // TODO: add once size and machine completion are implemented + // genericcli.Must(switchMachinesCmd.RegisterFlagCompletionFunc("size", c.Completion.SizeListCompletion)) + // genericcli.Must(switchMachinesCmd.RegisterFlagCompletionFunc("machine-id", c.Completion.MachineListCompletion)) + + switchConsoleCmd := &cobra.Command{ + Use: "console ", + Short: "connect to the switch console", + Long: "this requires a network connectivity to the ip address of the console server this switch is connected to.", + RunE: func(cmd *cobra.Command, args []string) error { + return sw.switchConsole(args) + }, + ValidArgsFunction: c.Completion.SwitchListCompletion, + } + + switchDetailCmd := &cobra.Command{ + Use: "detail", + Short: "switch details", + RunE: func(cmd *cobra.Command, args []string) error { + return sw.switchDetail() + }, + ValidArgsFunction: c.Completion.SwitchListCompletion, + } + + switchDetailCmd.Flags().String("id", "", "ID of the switch.") + switchDetailCmd.Flags().String("os-vendor", "", "OS vendor of this switch.") + switchDetailCmd.Flags().String("os-version", "", "OS version of this switch.") + switchDetailCmd.Flags().String("partition", "", "Partition of this switch.") + switchDetailCmd.Flags().String("rack", "", "Rack of this switch.") + + genericcli.Must(switchDetailCmd.RegisterFlagCompletionFunc("id", c.Completion.SwitchListCompletion)) + genericcli.Must(switchDetailCmd.RegisterFlagCompletionFunc("partition", c.Completion.PartitionListCompletion)) + genericcli.Must(switchDetailCmd.RegisterFlagCompletionFunc("rack", c.Completion.SwitchRackListCompletion)) + + switchMigrateCmd := &cobra.Command{ + Use: "migrate ", + Short: "migrate machine connections and other configuration from one switch to another", + ValidArgsFunction: c.Completion.SwitchListCompletion, + RunE: func(cmd *cobra.Command, args []string) error { + return sw.switchMigrate(args) + }, + } + + switchPortCmd := &cobra.Command{ + Use: "port", + Short: "sets the given switch port state up or down", + } + switchPortCmd.PersistentFlags().String("port", "", "the port to be changed.") + genericcli.Must(switchPortCmd.RegisterFlagCompletionFunc("port", c.Completion.SwitchListPorts)) + + switchPortUpCmd := &cobra.Command{ + Use: "up ", + Short: "sets the given switch port state up", + Long: "sets the port status to UP so the connected machine will be able to connect to the switch.", + RunE: func(cmd *cobra.Command, args []string) error { + return sw.port(args, apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_UP) + }, + ValidArgsFunction: c.Completion.SwitchListCompletion, + } + + switchPortDownCmd := &cobra.Command{ + Use: "down ", + Short: "sets the given switch port state down", + Long: "sets the port status to DOWN so the connected machine will not be able to connect to the switch.", + RunE: func(cmd *cobra.Command, args []string) error { + return sw.port(args, apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_DOWN) + }, + ValidArgsFunction: c.Completion.SwitchListCompletion, + } + + switchPortCmd.AddCommand(switchPortUpCmd, switchPortDownCmd) + + 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.", + Long: `Put a leaf switch into replace mode in preparation for physical replacement + +Operational steps to replace a switch: + +- Put the switch that needs to be replaced in replace mode with this command +- Replace the switch MAC address in the metal-stack deployment configuration +- Make sure that interfaces on the new switch do not get connected to the PXE-bridge immediately by setting the interfaces list of the respective leaf switch to [] in the metal-stack deployment configuration +- Deploy the management servers so that the dhcp servers will serve the right address and DHCP options to the new switch +- Replace the switch physically. Be careful to ensure that the cabling mirrors the remaining leaf exactly because the new switch information will be cloned from the remaining switch! Also make sure to have console access to the switch so you can start and monitor the install process +- If the switch is not in onie install mode but already has an operating system installed, put it into install mode with "sudo onie-select -i -f -v" and reboot it. Now the switch should be provisioned with a management IP from a management server, install itself with the right software image and receive license and ssh keys through ZTP. You can check whether that process has completed successfully with the command "sudo ztp -s". The ZTP state should be disabled and the result should be success. +- Deploy the switch plane and metal-core through metal-stack deployment CI job +- The switch will now register with its metal-api, and the metal-core service will receive the cloned interface and routing information. You can verify successful switch replacement by checking the interface and BGP configuration, and checking the switch status with "metalctlv2 switch ls -o wide"; it should now be operational again`, + RunE: func(cmd *cobra.Command, args []string) error { + return sw.switchReplace(args) + }, + ValidArgsFunction: c.Completion.SwitchListCompletion, + } + + switchSSHCmd := &cobra.Command{ + Use: "ssh ", + Short: "connect to the switch via ssh", + Long: "this requires a network connectivity to the management ip address of the switch.", + RunE: func(cmd *cobra.Command, args []string) error { + return sw.switchSSH(args) + }, + ValidArgsFunction: c.Completion.SwitchListCompletion, + } + + return genericcli.NewCmds(cmdsConfig, switchConnectedMachinesCmd, switchConsoleCmd, switchDetailCmd, switchMigrateCmd, switchPortCmd, switchReplaceCmd, switchSSHCmd) +} + +func (c *switchCmd) Get(id string) (*apiv2.Switch, error) { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + res, err := c.c.Client.Adminv2().Switch().Get(ctx, &adminv2.SwitchServiceGetRequest{Id: id}) + if err != nil { + return nil, err + } + + return res.Switch, nil +} + +func (c *switchCmd) List() ([]*apiv2.Switch, error) { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + vendor, err := enum.GetEnum[apiv2.SwitchOSVendor](viper.GetString("os-vendor")) + if err != nil { + return nil, err + } + + res, err := c.c.Client.Adminv2().Switch().List(ctx, &adminv2.SwitchServiceListRequest{ + Query: &apiv2.SwitchQuery{ + Id: pointer.Pointer(viper.GetString("id")), + Partition: pointer.Pointer(viper.GetString("partition")), + Rack: pointer.Pointer(viper.GetString("rack")), + Os: &apiv2.SwitchOSQuery{ + Vendor: &vendor, + Version: pointer.Pointer(viper.GetString("os-version")), + }, + }, + }) + if err != nil { + return nil, err + } + + return res.Switches, nil +} + +func (c *switchCmd) Create(rq any) (*apiv2.Switch, error) { + panic("unimplemented") +} + +func (c *switchCmd) Delete(id string) (*apiv2.Switch, error) { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + res, err := c.c.Client.Adminv2().Switch().Delete(ctx, &adminv2.SwitchServiceDeleteRequest{ + Id: id, + Force: viper.GetBool("force"), + }) + if err != nil { + return nil, err + } + + return res.Switch, nil +} + +func (c *switchCmd) Update(rq *adminv2.SwitchServiceUpdateRequest) (*apiv2.Switch, error) { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + res, err := c.c.Client.Adminv2().Switch().Update(ctx, rq) + if err != nil { + return nil, err + } + + return res.Switch, nil +} + +func (c *switchCmd) Convert(sw *apiv2.Switch) (string, any, *adminv2.SwitchServiceUpdateRequest, error) { + return sw.Id, nil, &adminv2.SwitchServiceUpdateRequest{ + Id: sw.Id, + Description: &sw.Description, + ReplaceMode: &sw.ReplaceMode, + ManagementIp: &sw.ManagementIp, + ManagementUser: sw.ManagementUser, + ConsoleCommand: sw.ConsoleCommand, + Nics: sw.Nics, + Os: sw.Os, + }, nil +} + +func (c *switchCmd) switchConnectedMachines() error { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + switches, err := c.List() + if err != nil { + return err + } + + err = sorters.SwitchSorter().SortBy(switches) + if err != nil { + return err + } + + var ( + id *string + partition *string + rack *string + size *string + ) + + if viper.IsSet("machine-id") { + id = pointer.Pointer(viper.GetString("machine-id")) + } + if viper.IsSet("partition") { + partition = pointer.Pointer(viper.GetString("partition")) + } + if viper.IsSet("rack") { + rack = pointer.Pointer(viper.GetString("rack")) + } + if viper.IsSet("size") { + size = pointer.Pointer(viper.GetString("size")) + } + + resp, err := c.c.Client.Adminv2().Machine().List(ctx, &adminv2.MachineServiceListRequest{ + Query: &apiv2.MachineQuery{ + Uuid: id, + Partition: partition, + Size: size, + Rack: rack, + }, + Partition: partition, + }) + if err != nil { + return err + } + + machines := map[string]*apiv2.Machine{} + for _, m := range resp.Machines { + machines[m.Uuid] = m + } + + return c.c.ListPrinter.Print(&tableprinters.SwitchesWithMachines{ + Switches: switches, + Machines: machines, + }) +} + +func (c *switchCmd) switchConsole(args []string) error { + id, err := genericcli.GetExactlyOneArg(args) + if err != nil { + return err + } + + resp, err := c.Get(id) + if err != nil { + return err + } + + if resp.ConsoleCommand == nil { + return fmt.Errorf(` + unable to connect to console because no console_command was specified for this switch + You can add a working console_command to every switch with metalctlv2 switch edit + A sample would look like: + + telnet console-server 7008`) + } + + parts := strings.Fields(*resp.ConsoleCommand) + + // nolint: gosec + cmd := exec.Command(parts[0]) + + if len(parts) > 1 { + // nolint: gosec + cmd = exec.Command(parts[0], parts[1:]...) + } + + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stdout + return cmd.Run() +} + +func (c *switchCmd) switchDetail() error { + switches, err := c.List() + if err != nil { + return err + } + + var switchDetails []tableprinters.SwitchDetail + for _, sw := range switches { + switchDetails = append(switchDetails, tableprinters.SwitchDetail{ + Switch: sw, + }) + } + + return c.c.ListPrinter.Print(switchDetails) +} + +func (c *switchCmd) switchMigrate(args []string) error { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + if count := len(args); count != 2 { + return fmt.Errorf("invalid number of arguments were provided; 2 are required, %d were passed", count) + } + + resp, err := c.c.Client.Adminv2().Switch().Migrate(ctx, &adminv2.SwitchServiceMigrateRequest{ + OldSwitch: args[0], + NewSwitch: args[1], + }) + if err != nil { + return err + } + + return c.c.DescribePrinter.Print(resp) +} + +func (c *switchCmd) port(args []string, status apiv2.SwitchPortStatus) error { + ctx, cancel := c.c.NewRequestContext() + defer cancel() + + id, err := genericcli.GetExactlyOneArg(args) + if err != nil { + return err + } + + portid := viper.GetString("port") + if portid == "" { + return fmt.Errorf("missing port") + } + + resp, err := c.c.Client.Adminv2().Switch().Port(ctx, &adminv2.SwitchServicePortRequest{ + Id: id, + NicName: portid, + Status: status, + }) + if err != nil { + return err + } + + return c.dumpPortState(resp.Switch, portid) +} + +func (c *switchCmd) switchReplace(args []string) error { + id, err := genericcli.GetExactlyOneArg(args) + if err != nil { + return err + } + + sw, err := c.Get(id) + if err != nil { + return err + } + + resp, err := c.Update(&adminv2.SwitchServiceUpdateRequest{ + Id: id, + UpdateMeta: &apiv2.UpdateMeta{ + UpdatedAt: timestamppb.Now(), + LockingStrategy: apiv2.OptimisticLockingStrategy_OPTIMISTIC_LOCKING_STRATEGY_SERVER, + }, + Description: &sw.Description, + ReplaceMode: apiv2.SwitchReplaceMode_SWITCH_REPLACE_MODE_REPLACE.Enum(), + Os: sw.Os, + }) + if err != nil { + return err + } + + return c.c.DescribePrinter.Print(resp) +} + +func (c *switchCmd) switchSSH(args []string) error { + id, err := genericcli.GetExactlyOneArg(args) + if err != nil { + return err + } + + resp, err := c.Get(id) + if err != nil { + return err + } + if resp.ManagementIp == "" || resp.ManagementUser == nil { + return fmt.Errorf("unable to connect to switch by ssh because no ip and user was stored for this switch, please restart metal-core on this switch") + } + + // nolint: gosec + cmd := exec.Command("ssh", fmt.Sprintf("%s@%s", pointer.SafeDeref(resp.ManagementUser), resp.ManagementIp)) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stdout + return cmd.Run() +} + +func (c *switchCmd) dumpPortState(sw *apiv2.Switch, portid string) error { + var state currentSwitchPortStateDump + + for _, con := range sw.MachineConnections { + if pointer.SafeDeref(pointer.SafeDeref(con).Nic).Name == portid { + state.Actual = con + break + } + } + for _, desired := range sw.Nics { + if pointer.SafeDeref(desired).Name == portid { + state.Desired = desired + break + } + } + + if state.Actual.Nic == nil { + return fmt.Errorf("no machine connected to port %s on switch %s", portid, sw.Id) + } + + return c.c.DescribePrinter.Print(state) +} + +type currentSwitchPortStateDump struct { + Actual *apiv2.MachineConnection `yaml:"actual"` + Desired *apiv2.SwitchNic `yaml:"desired"` +} diff --git a/cmd/completion/switch.go b/cmd/completion/switch.go new file mode 100644 index 0000000..7183a10 --- /dev/null +++ b/cmd/completion/switch.go @@ -0,0 +1,98 @@ +package completion + +import ( + adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" + "github.com/metal-stack/metal-lib/pkg/pointer" + "github.com/spf13/cobra" +) + +func (c *Completion) SwitchListCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + resp, err := c.Client.Adminv2().Switch().List(c.Ctx, &adminv2.SwitchServiceListRequest{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var ids []string + for _, s := range resp.Switches { + ids = append(ids, s.Id) + } + + return ids, cobra.ShellCompDirectiveNoFileComp +} + +func (c *Completion) PartitionListCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + resp, err := c.Client.Adminv2().Switch().List(c.Ctx, &adminv2.SwitchServiceListRequest{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var partitions []string + for _, s := range resp.Switches { + partitions = append(partitions, s.Partition) + } + + return partitions, cobra.ShellCompDirectiveNoFileComp +} + +func (c *Completion) SwitchRackListCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + resp, err := c.Client.Adminv2().Switch().List(c.Ctx, &adminv2.SwitchServiceListRequest{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var racks []string + for _, s := range resp.Switches { + racks = append(racks, pointer.SafeDeref(s.Rack)) + } + + return racks, cobra.ShellCompDirectiveNoFileComp +} + +func (c *Completion) SwitchOSVendorListCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + resp, err := c.Client.Adminv2().Switch().List(c.Ctx, &adminv2.SwitchServiceListRequest{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var oss []string + for _, s := range resp.Switches { + oss = append(oss, pointer.SafeDeref(s.Os).Vendor.String()) + } + + return oss, cobra.ShellCompDirectiveNoFileComp +} + +func (c *Completion) SwitchOSVersionListCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + resp, err := c.Client.Adminv2().Switch().List(c.Ctx, &adminv2.SwitchServiceListRequest{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var osVersions []string + for _, s := range resp.Switches { + osVersions = append(osVersions, pointer.SafeDeref(s.Os).Version) + } + + return osVersions, cobra.ShellCompDirectiveNoFileComp +} + +func (c *Completion) SwitchListPorts(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + // there is no switch selected so we cannot get the list of ports + return nil, cobra.ShellCompDirectiveNoFileComp + } + + resp, err := c.Client.Adminv2().Switch().Get(c.Ctx, &adminv2.SwitchServiceGetRequest{ + Id: args[0], + }) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var nics []string + for _, nic := range resp.Switch.Nics { + nics = append(nics, pointer.SafeDeref(nic).Name) + } + + return nics, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/completion/token.go b/cmd/completion/token.go index b7498f5..e23fbb2 100644 --- a/cmd/completion/token.go +++ b/cmd/completion/token.go @@ -1,7 +1,6 @@ package completion import ( - "fmt" "strings" "github.com/spf13/cobra" @@ -18,7 +17,6 @@ func (c *Completion) TokenListCompletion(cmd *cobra.Command, args []string, toCo var names []string for _, s := range resp.Tokens { - fmt.Println(s.Uuid) names = append(names, s.Uuid+"\t"+s.Description) } diff --git a/cmd/sorters/switch.go b/cmd/sorters/switch.go new file mode 100644 index 0000000..59985c2 --- /dev/null +++ b/cmd/sorters/switch.go @@ -0,0 +1,33 @@ +package sorters + +import ( + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/metal-lib/pkg/multisort" + "github.com/metal-stack/metal-lib/pkg/pointer" +) + +func SwitchSorter() *multisort.Sorter[*apiv2.Switch] { + return multisort.New(multisort.FieldMap[*apiv2.Switch]{ + "id": func(a, b *apiv2.Switch, descending bool) multisort.CompareResult { + return multisort.Compare(a.Id, b.Id, descending) + }, + "description": func(a, b *apiv2.Switch, descending bool) multisort.CompareResult { + return multisort.Compare(a.Description, b.Description, descending) + }, + "partition": func(a, b *apiv2.Switch, descending bool) multisort.CompareResult { + return multisort.Compare(a.Partition, b.Partition, descending) + }, + "rack": func(a, b *apiv2.Switch, descending bool) multisort.CompareResult { + return multisort.Compare(pointer.SafeDeref(a.Rack), pointer.SafeDeref(b.Rack), descending) + }, + "os": func(a, b *apiv2.Switch, descending bool) multisort.CompareResult { + return multisort.Compare(pointer.SafeDeref(a.Os).Vendor, pointer.SafeDeref(b.Os).Vendor, descending) + }, + "metal-core-version": func(a, b *apiv2.Switch, descending bool) multisort.CompareResult { + return multisort.Compare(pointer.SafeDeref(a.Os).MetalCoreVersion, pointer.SafeDeref(b.Os).MetalCoreVersion, descending) + }, + "management-ip": func(a, b *apiv2.Switch, descending bool) multisort.CompareResult { + return multisort.Compare(a.ManagementIp, b.ManagementIp, descending) + }, + }, multisort.Keys{{ID: "id"}}) +} diff --git a/cmd/tableprinters/common.go b/cmd/tableprinters/common.go index 19c30e7..9291220 100644 --- a/cmd/tableprinters/common.go +++ b/cmd/tableprinters/common.go @@ -12,6 +12,11 @@ import ( "github.com/metal-stack/metal-lib/pkg/pointer" ) +const ( + dot = "●" + nbr = " " +) + type TablePrinter struct { t *printers.TablePrinter } @@ -76,6 +81,11 @@ func (t *TablePrinter) ToHeaderAndRows(data any, wide bool) ([]string, [][]strin case []*apiv2.Health: return t.HealthTable(d, wide) + case []*apiv2.Switch: + return t.SwitchTable(d, wide) + case []SwitchDetail: + return t.SwitchDetailTable(d) + default: return nil, nil, fmt.Errorf("unknown table printer for type: %T", d) } diff --git a/cmd/tableprinters/switch.go b/cmd/tableprinters/switch.go new file mode 100644 index 0000000..dcad06b --- /dev/null +++ b/cmd/tableprinters/switch.go @@ -0,0 +1,283 @@ +package tableprinters + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" + "time" + + "github.com/fatih/color" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + + "github.com/metal-stack/metal-lib/pkg/pointer" +) + +func (t *TablePrinter) SwitchTable(switches []*apiv2.Switch, wide bool) ([]string, [][]string, error) { + var ( + rows [][]string + ) + + header := []string{"ID", "Partition", "Rack", "OS", "Status", "Last Sync"} + if wide { + header = []string{"ID", "Partition", "Rack", "OS", "Metalcore", "IP", "Mode", "Last Sync", "Sync Duration", "Last Error"} + t.t.DisableAutoWrap(true) + } + + for _, s := range switches { + var ( + id = s.Id + partition = s.Partition + rack = pointer.SafeDeref(s.Rack) + + syncTime time.Time + syncLast = "" + syncDurStr = "" + lastError = "" + shortStatus = nbr + allUp = true + ) + + for _, c := range s.MachineConnections { + if c.Nic == nil { + continue + } + + if c.Nic.State == nil { + allUp = false + lastError = fmt.Sprintf("port status of %q is unknown", c.Nic.Name) + break + } + + desired := c.Nic.State.Desired + actual := c.Nic.State.Actual + allUp = allUp && actual == apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_UP + + if desired != nil && actual != *desired { + lastError = fmt.Sprintf("%q is %s but should be %s", c.Nic.Name, portStatusString(actual), portStatusString(*desired)) + break + } + + if !allUp { + lastError = fmt.Sprintf("%q is %s", c.Nic.Name, c.Nic.State.Actual) + break + } + } + + if s.LastSync != nil { + var ( + syncAge time.Duration + syncDur time.Duration + ) + + if s.LastSync.Time != nil && !s.LastSync.Time.AsTime().IsZero() { + syncTime = s.LastSync.Time.AsTime() + syncAge = time.Since(syncTime) + } + if s.LastSync.Duration != nil { + syncDur = s.LastSync.Duration.AsDuration().Round(time.Millisecond) + } + + switch { + case syncAge >= 10*time.Minute, syncDur >= 30*time.Second: + shortStatus = color.RedString(dot) + case syncAge >= time.Minute, syncDur >= 20*time.Second, !allUp: + shortStatus = color.YellowString(dot) + default: + shortStatus = color.GreenString(dot) + } + + if syncAge > 0 { + syncLast = humanizeDuration(syncAge) + " ago" + } + if syncDur > 0 { + syncDurStr = fmt.Sprintf("%v", syncDur) + } + } + + if s.LastSyncError != nil { + var ( + errorTime time.Time + error string + ) + + if s.LastSyncError.Time != nil { + errorTime = s.LastSyncError.Time.AsTime() + } + if s.LastSyncError.Error != nil { + error = *s.LastSyncError.Error + } + // after 7 days we do not show sync errors anymore + if !errorTime.IsZero() && time.Since(errorTime) < 7*24*time.Hour { + lastError = fmt.Sprintf("%s ago: %s", humanizeDuration(time.Since(errorTime)), error) + + if errorTime.After(syncTime) { + shortStatus = color.RedString(dot) + } + } + } + + var mode string + switch s.ReplaceMode { + case apiv2.SwitchReplaceMode_SWITCH_REPLACE_MODE_REPLACE: + shortStatus = nbr + color.RedString(dot) + mode = "replace" + default: + mode = "operational" + } + + os := "" + osIcon := "" + metalCore := "" + if s.Os != nil { + switch s.Os.Vendor { + case apiv2.SwitchOSVendor_SWITCH_OS_VENDOR_CUMULUS: + osIcon = "🐢" + case apiv2.SwitchOSVendor_SWITCH_OS_VENDOR_SONIC: + osIcon = "🦔" + default: + osIcon = s.Os.Vendor.String() + } + + os = s.Os.Vendor.String() + if s.Os.Version != "" { + os = fmt.Sprintf("%s (%s)", os, s.Os.Version) + } + // metal core version is very long: v0.9.1 (1d5e42ea), tags/v0.9.1-0-g1d5e42e, go1.20.5 + metalCore = strings.Split(s.Os.MetalCoreVersion, ",")[0] + } + + if wide { + rows = append(rows, []string{id, partition, rack, os, metalCore, s.ManagementIp, mode, syncLast, syncDurStr, lastError}) + } else { + rows = append(rows, []string{id, partition, rack, osIcon, shortStatus, syncLast}) + } + } + + return header, rows, nil +} + +type SwitchesWithMachines struct { + Switches []*apiv2.Switch `yaml:"switches"` + Machines map[string]*apiv2.Machine `yaml:"machines"` +} + +func (t *TablePrinter) SwitchWithConnectedMachinesTable(data *SwitchesWithMachines, wide bool) ([]string, [][]string, error) { + panic("unimplemented") +} + +func switchInterfaceNameLessFunc(conns []*apiv2.MachineConnection) func(i, j int) bool { + numberRegex := regexp.MustCompile("([0-9]+)") + + 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 { + *apiv2.Switch +} + +func (t *TablePrinter) SwitchDetailTable(switches []SwitchDetail) ([]string, [][]string, error) { + var ( + header = []string{"Partition", "Rack", "Switch", "Port", "Machine", "VNI-Filter", "CIDR-Filter"} + rows [][]string + ) + + for _, sw := range switches { + filterByNic := map[string]*apiv2.BGPFilter{} + for _, nic := range sw.Nics { + if nic == nil { + continue + } + + if nic.BgpFilter != nil { + filterByNic[nic.Name] = nic.BgpFilter + } + } + + for _, conn := range sw.MachineConnections { + if conn == nil || conn.Nic == nil { + continue + } + + filter := filterByNic[conn.Nic.Name] + row := append([]string{sw.Partition, pointer.SafeDeref(sw.Rack), sw.Id, conn.Nic.Name, conn.MachineId}, filterColumns(filter, 0)...) + rows = append(rows, row) + + if filter == nil { + continue + } + + max := math.Max(float64(len(filter.Cidrs)), float64(len(filter.Vnis))) + for i := 1; i < int(max); i++ { + row = append([]string{"", "", "", "", ""}, filterColumns(filter, i)...) + rows = append(rows, row) + } + } + } + + return header, rows, nil +} + +func filterColumns(filter *apiv2.BGPFilter, i int) []string { + var ( + vni string + cidr string + ) + + if filter == nil { + return nil + } + + if len(filter.Vnis) > i { + vni = filter.Vnis[i] + } + if len(filter.Cidrs) > i { + cidr = filter.Cidrs[i] + } + + return []string{vni, cidr} +} + +func portStatusString(status apiv2.SwitchPortStatus) string { + switch status { + case apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_UP: + return "UP" + case apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_DOWN: + return "DOWN" + case apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_UNKNOWN: + return "UNKNOWN" + case apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_UNSPECIFIED: + return "UNSPECIFIED" + default: + return "" + } +} diff --git a/cmd/tableprinters/switch_test.go b/cmd/tableprinters/switch_test.go new file mode 100644 index 0000000..bb17aaf --- /dev/null +++ b/cmd/tableprinters/switch_test.go @@ -0,0 +1,502 @@ +package tableprinters + +import ( + "reflect" + "sort" + "testing" + "time" + + "github.com/fatih/color" + "github.com/google/go-cmp/cmp" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/metal-lib/pkg/genericcli/printers" + "github.com/metal-stack/metal-lib/pkg/pointer" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func Test_switchInterfaceNameLessFunc(t *testing.T) { + tests := []struct { + name string + conns []*apiv2.MachineConnection + want []*apiv2.MachineConnection + }{ + { + name: "sorts interface names for cumulus-like interface names", + conns: []*apiv2.MachineConnection{ + {Nic: &apiv2.SwitchNic{Name: "swp10"}}, + {Nic: &apiv2.SwitchNic{Name: "swp1s4"}}, + {Nic: &apiv2.SwitchNic{Name: "swp1s3"}}, + {Nic: &apiv2.SwitchNic{Name: "swp1s1"}}, + {Nic: &apiv2.SwitchNic{Name: "swp1s2"}}, + {Nic: &apiv2.SwitchNic{Name: "swp9"}}, + }, + want: []*apiv2.MachineConnection{ + {Nic: &apiv2.SwitchNic{Name: "swp1s1"}}, + {Nic: &apiv2.SwitchNic{Name: "swp1s2"}}, + {Nic: &apiv2.SwitchNic{Name: "swp1s3"}}, + {Nic: &apiv2.SwitchNic{Name: "swp1s4"}}, + {Nic: &apiv2.SwitchNic{Name: "swp9"}}, + {Nic: &apiv2.SwitchNic{Name: "swp10"}}, + }, + }, + { + name: "sorts interface names for sonic-like interface names", + conns: []*apiv2.MachineConnection{ + {Nic: &apiv2.SwitchNic{Name: "Ethernet3"}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet49"}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet10"}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet2"}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet1"}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet11"}}, + }, + want: []*apiv2.MachineConnection{ + {Nic: &apiv2.SwitchNic{Name: "Ethernet1"}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet2"}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet3"}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet10"}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet11"}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet49"}}, + }, + }, + { + name: "sorts interface names edge cases", + conns: []*apiv2.MachineConnection{ + {Nic: &apiv2.SwitchNic{Name: "123"}}, + {Nic: &apiv2.SwitchNic{Name: ""}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet1"}}, + {Nic: &apiv2.SwitchNic{Name: "swp1s4w5"}}, + {Nic: &apiv2.SwitchNic{Name: "foo"}}, + {Nic: &apiv2.SwitchNic{Name: "swp1s3w3"}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet100"}}, + {Nic: &apiv2.SwitchNic{Name: "swp1s4w6"}}, + {Nic: &apiv2.SwitchNic{Name: ""}}, + }, + want: []*apiv2.MachineConnection{ + {Nic: &apiv2.SwitchNic{Name: "swp1s3w3"}}, + {Nic: &apiv2.SwitchNic{Name: "swp1s4w5"}}, + {Nic: &apiv2.SwitchNic{Name: "swp1s4w6"}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet1"}}, + {Nic: &apiv2.SwitchNic{Name: "Ethernet100"}}, + {Nic: &apiv2.SwitchNic{Name: ""}}, + {Nic: &apiv2.SwitchNic{Name: ""}}, + {Nic: &apiv2.SwitchNic{Name: "123"}}, + {Nic: &apiv2.SwitchNic{Name: "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, protocmp.Transform()); diff != "" { + t.Errorf("diff (+got -want):\n %s", diff) + } + }) + } +} + +func Test_filterColumns(t *testing.T) { + tests := []struct { + name string + filter *apiv2.BGPFilter + i int + want []string + }{ + { + name: "filter is nil", + filter: nil, + i: 1, + want: nil, + }, + { + name: "i exceeds vni and cidr length", + filter: &apiv2.BGPFilter{ + Cidrs: []string{"1.1.1.1/32"}, + Vnis: []string{"120"}, + }, + i: 1, + want: []string{"", ""}, + }, + { + name: "i exceeds vni but not cidr length", + filter: &apiv2.BGPFilter{ + Cidrs: []string{"1.1.1.1/32", "2.2.2.2/32"}, + Vnis: []string{"120"}, + }, + i: 1, + want: []string{"", "2.2.2.2/32"}, + }, + { + name: "i exceeds cidr but not vni length", + filter: &apiv2.BGPFilter{ + Cidrs: []string{"1.1.1.1/32", "2.2.2.2/32"}, + Vnis: []string{"120", "32", "400"}, + }, + i: 2, + want: []string{"400", ""}, + }, + { + name: "both vnis and cidr within range of i", + filter: &apiv2.BGPFilter{ + Cidrs: []string{"1.1.1.1/32", "2.2.2.2/32", "3.3.3.3/32"}, + Vnis: []string{"120", "32", "400"}, + }, + i: 2, + want: []string{"400", "3.3.3.3/32"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := filterColumns(tt.filter, tt.i); !reflect.DeepEqual(got, tt.want) { + t.Errorf("filterColumns() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTablePrinter_SwitchTable(t *testing.T) { + now := timestamppb.Now() + tests := []struct { + name string + switches []*apiv2.Switch + wide bool + wantHeader []string + wantRows [][]string + }{ + { + name: "switches empty", + switches: []*apiv2.Switch{}, + wide: false, + wantHeader: []string{"ID", "Partition", "Rack", "OS", "Status", "Last Sync"}, + wantRows: nil, + }, + { + name: "some switches", + switches: []*apiv2.Switch{ + { + Id: "r01leaf01", + Rack: pointer.Pointer("rack01"), + Partition: "partition-a", + ReplaceMode: apiv2.SwitchReplaceMode_SWITCH_REPLACE_MODE_OPERATIONAL, + Os: &apiv2.SwitchOS{ + Vendor: apiv2.SwitchOSVendor_SWITCH_OS_VENDOR_SONIC, + }, + MachineConnections: []*apiv2.MachineConnection{ + { + Nic: &apiv2.SwitchNic{ + Name: "Ethernet0", + State: &apiv2.NicState{ + Desired: apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_DOWN.Enum(), + Actual: apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_UP, + }, + }, + }, + }, + LastSync: &apiv2.SwitchSync{ + Time: now, + }, + LastSyncError: &apiv2.SwitchSync{ + Time: timestamppb.New(now.AsTime().Add(-7 * 24 * time.Hour)), + Error: pointer.Pointer("sync took too long"), + }, + }, + { + Id: "r01leaf02", + Rack: pointer.Pointer("rack01"), + Partition: "partition-a", + ReplaceMode: apiv2.SwitchReplaceMode_SWITCH_REPLACE_MODE_REPLACE, + Os: &apiv2.SwitchOS{ + Vendor: apiv2.SwitchOSVendor_SWITCH_OS_VENDOR_CUMULUS, + }, + LastSync: &apiv2.SwitchSync{ + Time: now, + }, + LastSyncError: &apiv2.SwitchSync{ + Time: timestamppb.New(now.AsTime().Add(time.Hour - 7*24*time.Hour)), + Error: pointer.Pointer("sync took too long"), + }, + }, + { + Id: "r02leaf01", + Rack: pointer.Pointer("rack02"), + Partition: "partition-a", + Os: &apiv2.SwitchOS{}, + LastSync: &apiv2.SwitchSync{ + Time: timestamppb.New(now.AsTime().Add(-time.Hour)), + }, + LastSyncError: &apiv2.SwitchSync{ + Time: now, + }, + }, + { + Id: "r02leaf02", + Rack: pointer.Pointer("rack02"), + Partition: "partition-a", + LastSync: &apiv2.SwitchSync{ + Time: timestamppb.New(now.AsTime().Add(-10 * time.Minute)), + }, + }, + { + Id: "r03leaf01", + Rack: pointer.Pointer("rack03"), + Partition: "partition-a", + LastSync: &apiv2.SwitchSync{ + Time: now, + Duration: durationpb.New(20 * time.Second), + }, + }, + { + Id: "r03leaf02", + Rack: pointer.Pointer("rack03"), + Partition: "partition-a", + LastSync: &apiv2.SwitchSync{}, + MachineConnections: []*apiv2.MachineConnection{ + { + MachineId: "m1", + Nic: &apiv2.SwitchNic{ + Name: "Ethernet1", + State: &apiv2.NicState{ + Actual: apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_DOWN, + }, + }, + }, + }, + }, + }, + wide: false, + wantHeader: []string{"ID", "Partition", "Rack", "OS", "Status", "Last Sync"}, + wantRows: [][]string{ + // FIXME: color of the dots is ignored; how to test for correct colors? + {"r01leaf01", "partition-a", "rack01", "🦔", color.GreenString(dot), "0s ago"}, // status green but error because one port is not in its desired state + {"r01leaf02", "partition-a", "rack01", "🐢", nbr + color.RedString(dot), "0s ago"}, // status red because in replace mode + {"r02leaf01", "partition-a", "rack02", apiv2.SwitchOSVendor_SWITCH_OS_VENDOR_UNSPECIFIED.String(), color.RedString(dot), "1h ago"}, // status red because last error came later than last sync + {"r02leaf02", "partition-a", "rack02", "", color.RedString(dot), "10m ago"}, // status red because last sync is too long ago + {"r03leaf01", "partition-a", "rack03", "", color.YellowString(dot), "0s ago"}, // status yellow because last sync duration was too long + {"r03leaf02", "partition-a", "rack03", "", color.YellowString(dot), ""}, // status yellow because not all connceted ports are up + }, + }, + { + name: "some switches wide", + switches: []*apiv2.Switch{ + { + Id: "r01leaf01", + Rack: pointer.Pointer("rack01"), + Partition: "partition-a", + ReplaceMode: apiv2.SwitchReplaceMode_SWITCH_REPLACE_MODE_OPERATIONAL, + ManagementIp: "1.1.1.1", + Os: &apiv2.SwitchOS{ + Vendor: apiv2.SwitchOSVendor_SWITCH_OS_VENDOR_SONIC, + MetalCoreVersion: "v0.15.0", + }, + MachineConnections: []*apiv2.MachineConnection{ + { + Nic: &apiv2.SwitchNic{ + Name: "Ethernet0", + State: &apiv2.NicState{ + Desired: apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_DOWN.Enum(), + Actual: apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_UP, + }, + }, + }, + }, + LastSync: &apiv2.SwitchSync{ + Time: now, + Duration: durationpb.New(time.Second), + }, + LastSyncError: &apiv2.SwitchSync{ + Time: timestamppb.New(now.AsTime().Add(-7 * 24 * time.Hour)), + Error: pointer.Pointer("sync took too long"), + }, + }, + { + Id: "r01leaf02", + Rack: pointer.Pointer("rack01"), + Partition: "partition-a", + ReplaceMode: apiv2.SwitchReplaceMode_SWITCH_REPLACE_MODE_REPLACE, + ManagementIp: "2.2.2.2", + Os: &apiv2.SwitchOS{ + Vendor: apiv2.SwitchOSVendor_SWITCH_OS_VENDOR_CUMULUS, + MetalCoreVersion: "v0.13.0", + }, + LastSync: &apiv2.SwitchSync{ + Time: now, + }, + LastSyncError: &apiv2.SwitchSync{ + Time: timestamppb.New(now.AsTime().Add(time.Hour - 7*24*time.Hour)), + Error: pointer.Pointer("sync took too long"), + }, + }, + { + Id: "r02leaf01", + Rack: pointer.Pointer("rack02"), + Partition: "partition-a", + ManagementIp: "3.3.3.3", + Os: &apiv2.SwitchOS{}, + LastSync: &apiv2.SwitchSync{ + Time: timestamppb.New(now.AsTime().Add(-time.Hour)), + }, + LastSyncError: &apiv2.SwitchSync{ + Time: now, + Error: pointer.Pointer("error"), + }, + }, + }, + wide: true, + wantHeader: []string{"ID", "Partition", "Rack", "OS", "Metalcore", "IP", "Mode", "Last Sync", "Sync Duration", "Last Error"}, + wantRows: [][]string{ + {"r01leaf01", "partition-a", "rack01", apiv2.SwitchOSVendor_SWITCH_OS_VENDOR_SONIC.String(), "v0.15.0", "1.1.1.1", "operational", "0s ago", "1s", "\"Ethernet0\" is UP but should be DOWN"}, + {"r01leaf02", "partition-a", "rack01", apiv2.SwitchOSVendor_SWITCH_OS_VENDOR_CUMULUS.String(), "v0.13.0", "2.2.2.2", "replace", "0s ago", "", "6d 23h ago: sync took too long"}, + {"r02leaf01", "partition-a", "rack02", apiv2.SwitchOSVendor_SWITCH_OS_VENDOR_UNSPECIFIED.String(), "", "3.3.3.3", "operational", "1h ago", "", "0s ago: error"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tp := New() + p := printers.NewTablePrinter(&printers.TablePrinterConfig{ + ToHeaderAndRows: tp.ToHeaderAndRows, + Wide: tt.wide, + }) + tp.SetPrinter(p) + + gotHeader, gotRows, err := tp.SwitchTable(tt.switches, tt.wide) + if err != nil { + t.Errorf("TablePrinter.SwitchTable() error = %v", err) + return + } + if diff := cmp.Diff(tt.wantHeader, gotHeader); diff != "" { + t.Errorf("TablePrinter.SwitchTable() diff header = %s", diff) + } + if diff := cmp.Diff(tt.wantRows, gotRows); diff != "" { + t.Errorf("TablePrinter.SwitchTable() diff rows = %s", diff) + } + }) + } +} + +func TestTablePrinter_SwitchDetailTable(t *testing.T) { + tests := []struct { + name string + switches []SwitchDetail + wantHeader []string + wantRows [][]string + }{ + { + name: "empty switches", + switches: []SwitchDetail{}, + wantHeader: []string{"Partition", "Rack", "Switch", "Port", "Machine", "VNI-Filter", "CIDR-Filter"}, + wantRows: nil, + }, + { + name: "some switches", + switches: []SwitchDetail{ + { + Switch: &apiv2.Switch{ + Id: "leaf01", + Rack: pointer.Pointer("rack01"), + Partition: "partition-a", + Nics: []*apiv2.SwitchNic{ + { + Name: "Ethernet0", + BgpFilter: &apiv2.BGPFilter{ + Cidrs: []string{"1.1.1.0/24", "2.2.2.0/24"}, + Vnis: []string{"104"}, + }, + }, + { + Name: "Ethernet1", + }, + }, + MachineConnections: []*apiv2.MachineConnection{ + { + MachineId: "m1", + Nic: &apiv2.SwitchNic{ + Name: "Ethernet0", + BgpFilter: &apiv2.BGPFilter{ + Cidrs: []string{"1.1.1.0/24", "2.2.2.0/24"}, + Vnis: []string{"104"}, + }, + }, + }, + { + MachineId: "m2", + Nic: &apiv2.SwitchNic{ + Name: "Ethernet1", + }, + }, + }, + }, + }, + { + Switch: &apiv2.Switch{ + Id: "leaf02", + Rack: pointer.Pointer("rack01"), + Partition: "partition-a", + Nics: []*apiv2.SwitchNic{ + { + Name: "Ethernet0", + BgpFilter: &apiv2.BGPFilter{ + Cidrs: []string{"1.1.1.0/24", "2.2.2.0/24"}, + Vnis: []string{"150"}, + }, + }, + { + Name: "Ethernet1", + }, + }, + MachineConnections: []*apiv2.MachineConnection{ + { + MachineId: "m1", + Nic: &apiv2.SwitchNic{ + Name: "Ethernet0", + BgpFilter: &apiv2.BGPFilter{ + Cidrs: []string{"1.1.1.0/24", "2.2.2.0/24"}, + Vnis: []string{"150"}, + }, + }, + }, + { + MachineId: "m2", + Nic: &apiv2.SwitchNic{ + Name: "Ethernet1", + }, + }, + }, + }, + }, + }, + wantHeader: []string{"Partition", "Rack", "Switch", "Port", "Machine", "VNI-Filter", "CIDR-Filter"}, + wantRows: [][]string{ + {"partition-a", "rack01", "leaf01", "Ethernet0", "m1", "104", "1.1.1.0/24"}, + {"", "", "", "", "", "", "2.2.2.0/24"}, + {"partition-a", "rack01", "leaf01", "Ethernet1", "m2"}, + {"partition-a", "rack01", "leaf02", "Ethernet0", "m1", "150", "1.1.1.0/24"}, + {"", "", "", "", "", "", "2.2.2.0/24"}, + {"partition-a", "rack01", "leaf02", "Ethernet1", "m2"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tp := New() + p := printers.NewTablePrinter(&printers.TablePrinterConfig{ + ToHeaderAndRows: tp.ToHeaderAndRows, + }) + tp.SetPrinter(p) + + gotHeader, gotRows, err := tp.SwitchDetailTable(tt.switches) + if err != nil { + t.Errorf("TablePrinter.SwitchDetailTable() error = %v", err) + return + } + if diff := cmp.Diff(tt.wantHeader, gotHeader); diff != "" { + t.Errorf("TablePrinter.SwitchDetailTable() diff header = %s", diff) + } + if diff := cmp.Diff(tt.wantRows, gotRows); diff != "" { + t.Errorf("TablePrinter.SwitchDetailTable() diff rows = %s", diff) + } + }) + } +} diff --git a/go.mod b/go.mod index 9399bc5..5ba5e23 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/fatih/color v1.18.0 github.com/google/go-cmp v0.7.0 - github.com/metal-stack/api v0.0.35 + github.com/metal-stack/api v0.0.40 github.com/metal-stack/metal-lib v0.23.5 github.com/metal-stack/v v1.0.3 github.com/spf13/afero v1.15.0 @@ -15,12 +15,12 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 golang.org/x/net v0.46.0 - google.golang.org/protobuf v1.36.10 + google.golang.org/protobuf v1.36.11 sigs.k8s.io/yaml v1.6.0 ) require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.10-20250912141014-52f32327d4b0.1 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 // indirect connectrpc.com/connect v1.19.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect @@ -35,7 +35,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/klauspost/compress v1.18.1 // indirect + github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/connect-compress/v2 v2.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -58,7 +58,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/text v0.33.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apimachinery v0.34.1 // indirect diff --git a/go.sum b/go.sum index cbca136..e429bee 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.10-20250912141014-52f32327d4b0.1 h1:31on4W/yPcV4nZHL4+UCiCvLPsMqe/vJcNg8Rci0scc= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.10-20250912141014-52f32327d4b0.1/go.mod h1:fUl8CEN/6ZAMk6bP8ahBJPUJw7rbp+j4x+wCcYi2IG4= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 h1:j9yeqTWEFrtimt8Nng2MIeRrpoCvQzM9/g25XTvqUGg= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= @@ -39,8 +39,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= -github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/connect-compress/v2 v2.1.0 h1:8fM8QrVeHT69e5VVSh4yjDaQASYIvOp2uMZq7nVLj2U= github.com/klauspost/connect-compress/v2 v2.1.0/go.mod h1:Ayurh2wscMMx3AwdGGVL+ylSR5316WfApREDgsqHyH8= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -53,10 +53,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/metal-stack/api v0.0.35-0.20251124101516-a3076941d0f8 h1:PGBiFwASqhtmI6dGzVo0IEVQiBTIsQ2eo3pMriPViNY= -github.com/metal-stack/api v0.0.35-0.20251124101516-a3076941d0f8/go.mod h1:EBwS/oZr5tIcnV6hM7iK4aBQrw4wlU7vF5p+O1p3YIU= -github.com/metal-stack/api v0.0.35 h1:XxxYKTscSeYJg/ftL519nY3FAZ01atPeyD7+Zz/amQQ= -github.com/metal-stack/api v0.0.35/go.mod h1:EBwS/oZr5tIcnV6hM7iK4aBQrw4wlU7vF5p+O1p3YIU= +github.com/metal-stack/api v0.0.40 h1:3Uzocg7cZqNdFZKgyuSDKF6m2DYR0gA5AgktFoZk+XY= +github.com/metal-stack/api v0.0.40/go.mod h1:DtKUrGbmCj5N+ECPikIIgwNmK+m1FCRvu0oz01dhH5E= github.com/metal-stack/metal-lib v0.23.5 h1:ozrkB3DNr3Cqn8nkBvmzc/KKpYqC1j1mv2OVOj8i7Ac= github.com/metal-stack/metal-lib v0.23.5/go.mod h1:7uyHIrE19dkLwCZyeh2jmd7IEq5pEpzrzUGLoMN1eqY= github.com/metal-stack/v v1.0.3 h1:Sh2oBlnxrCUD+mVpzfC8HiqL045YWkxs0gpTvkjppqs= @@ -113,10 +111,10 @@ golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=