From a761fe55160a6c969cbf359fd65f7f5a4b50ce00 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Mon, 19 Jan 2026 09:46:07 +0100 Subject: [PATCH 1/8] wip --- cmd/admin/v1/commands.go | 1 + cmd/admin/v1/switch.go | 84 ++++++++++++++++++++++ cmd/completion/switch.go | 29 ++++++++ cmd/tableprinters/common.go | 9 +++ cmd/tableprinters/switch.go | 137 ++++++++++++++++++++++++++++++++++++ go.mod | 10 +-- go.sum | 22 +++--- 7 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 cmd/admin/v1/switch.go create mode 100644 cmd/completion/switch.go create mode 100644 cmd/tableprinters/switch.go diff --git a/cmd/admin/v1/commands.go b/cmd/admin/v1/commands.go index 52d9c49..00f2523 100644 --- a/cmd/admin/v1/commands.go +++ b/cmd/admin/v1/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/v1/switch.go b/cmd/admin/v1/switch.go new file mode 100644 index 0000000..b1997d7 --- /dev/null +++ b/cmd/admin/v1/switch.go @@ -0,0 +1,84 @@ +package v1 + +import ( + 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/metal-lib/pkg/genericcli" + "github.com/metal-stack/metal-lib/pkg/genericcli/printers" + "github.com/metal-stack/metal-lib/pkg/multisort" + "github.com/spf13/cobra" +) + +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("name", "", "Name 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("name", c.Completion.SwitchNameListCompletion)) + 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") + }, + } + + return genericcli.NewCmds(cmdsConfig) +} + +func (c *switchCmd) Get(id string) (*apiv2.Switch, error) { + panic("unimplemented") +} + +func (c *switchCmd) List() ([]*apiv2.Switch, error) { + panic("unimplemented") +} + +func (c *switchCmd) Create(rq any) (*apiv2.Switch, error) { + panic("unimplemented") +} + +func (c *switchCmd) Delete(id string) (*apiv2.Switch, error) { + panic("unimplemented") +} + +func (c *switchCmd) Convert(sw *apiv2.Switch) (string, any, *adminv2.SwitchServiceUpdateRequest, error) { + panic("unimplemented") +} + +func (c *switchCmd) Update(rq *adminv2.SwitchServiceUpdateRequest) (*apiv2.Switch, error) { + panic("unimplemented") +} diff --git a/cmd/completion/switch.go b/cmd/completion/switch.go new file mode 100644 index 0000000..bd87ca8 --- /dev/null +++ b/cmd/completion/switch.go @@ -0,0 +1,29 @@ +package completion + +import ( + "github.com/spf13/cobra" +) + +func (c *Completion) SwitchListCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + panic("unimplemented") +} + +func (c *Completion) SwitchNameListCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + panic("unimplemented") +} + +func (c *Completion) PartitionListCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + panic("unimplemented") +} + +func (c *Completion) SwitchRackListCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + panic("unimplemented") +} + +func (c *Completion) SwitchOSVendorListCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + panic("unimplemented") +} + +func (c *Completion) SwitchOSVersionListCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + panic("unimplemented") +} diff --git a/cmd/tableprinters/common.go b/cmd/tableprinters/common.go index 19c30e7..075faff 100644 --- a/cmd/tableprinters/common.go +++ b/cmd/tableprinters/common.go @@ -12,6 +12,15 @@ import ( "github.com/metal-stack/metal-lib/pkg/pointer" ) +const ( + dot = "●" + halfpie = "◒" + threequarterpie = "◕" + nbr = " " + poweron = "⏻" + powersleep = "⏾" +) + type TablePrinter struct { t *printers.TablePrinter } diff --git a/cmd/tableprinters/switch.go b/cmd/tableprinters/switch.go new file mode 100644 index 0000000..5fcf9f2 --- /dev/null +++ b/cmd/tableprinters/switch.go @@ -0,0 +1,137 @@ +package tableprinters + +import ( + "fmt" + "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(data []*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 data { + 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, c.Nic.State.Actual, desired) + break + } + + if !allUp { + lastError = fmt.Sprintf("%q is %s", c.Nic.Name, c.Nic.State.Actual) + break + } + } + + // FIXME: nil pointer checks and refactor + if s.LastSync != nil { + syncTime = s.LastSync.Time.AsTime() + syncAge := time.Since(syncTime) + syncDur := s.LastSync.Duration.AsDuration().Round(time.Millisecond) + + if syncAge >= time.Minute*10 || syncDur >= 30*time.Second { + shortStatus = color.RedString(dot) + } else if syncAge >= time.Minute*1 || syncDur >= 20*time.Second { + shortStatus = color.YellowString(dot) + } else { + shortStatus = color.GreenString(dot) + if !allUp { + shortStatus = color.YellowString(dot) + } + } + + syncLast = humanizeDuration(syncAge) + " ago" + syncDurStr = fmt.Sprintf("%v", syncDur) + } + + // FIXME: nil pointer checks and refactor + if s.LastSyncError != nil { + errorTime := s.LastSyncError.Time.AsTime() + // 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)), s.LastSyncError.Error) + + if errorTime.After(s.LastSync.Time.AsTime()) { + 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 +} diff --git a/go.mod b/go.mod index 9399bc5..cd30b20 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.38-0.20260114100931-81fb1b7d4b93 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.2 // 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.32.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..87ad89c 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.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/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.38-0.20260114100931-81fb1b7d4b93 h1:GfDGUyn3KA7LI/NyO6smxFs2xkW6bKQxce77cDylgJ4= +github.com/metal-stack/api v0.0.38-0.20260114100931-81fb1b7d4b93/go.mod h1:lVDIha/gViLpYuJi+OhQIQCeh6XYdzGxrtbtJTJ94eI= 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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +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= From 82b4e3afcac2e11305cb82fb19f168c0f2b92d48 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Mon, 19 Jan 2026 09:56:39 +0100 Subject: [PATCH 2/8] rename package --- cmd/admin/v2/switch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/admin/v2/switch.go b/cmd/admin/v2/switch.go index b1997d7..9aaa4ee 100644 --- a/cmd/admin/v2/switch.go +++ b/cmd/admin/v2/switch.go @@ -1,4 +1,4 @@ -package v1 +package v2 import ( adminv2 "github.com/metal-stack/api/go/metalstack/admin/v2" From ee2a5b6c3553e771b13bd13e48093f3dec8a4056 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Mon, 19 Jan 2026 11:48:32 +0100 Subject: [PATCH 3/8] refactor switch table --- cmd/tableprinters/common.go | 8 ++---- cmd/tableprinters/switch.go | 55 +++++++++++++++++++++++++------------ 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/cmd/tableprinters/common.go b/cmd/tableprinters/common.go index 075faff..5a519ca 100644 --- a/cmd/tableprinters/common.go +++ b/cmd/tableprinters/common.go @@ -13,12 +13,8 @@ import ( ) const ( - dot = "●" - halfpie = "◒" - threequarterpie = "◕" - nbr = " " - poweron = "⏻" - powersleep = "⏾" + dot = "●" + nbr = " " ) type TablePrinter struct { diff --git a/cmd/tableprinters/switch.go b/cmd/tableprinters/switch.go index 5fcf9f2..2ca9c27 100644 --- a/cmd/tableprinters/switch.go +++ b/cmd/tableprinters/switch.go @@ -10,7 +10,7 @@ import ( "github.com/metal-stack/metal-lib/pkg/pointer" ) -func (t *TablePrinter) SwitchTable(data []*apiv2.Switch, wide bool) ([]string, [][]string, error) { +func (t *TablePrinter) SwitchTable(switches []*apiv2.Switch, wide bool) ([]string, [][]string, error) { var ( rows [][]string ) @@ -22,7 +22,7 @@ func (t *TablePrinter) SwitchTable(data []*apiv2.Switch, wide bool) ([]string, [ t.t.DisableAutoWrap(true) } - for _, s := range data { + for _, s := range switches { var ( id = s.Id partition = s.Partition @@ -62,35 +62,54 @@ func (t *TablePrinter) SwitchTable(data []*apiv2.Switch, wide bool) ([]string, [ } } - // FIXME: nil pointer checks and refactor if s.LastSync != nil { - syncTime = s.LastSync.Time.AsTime() - syncAge := time.Since(syncTime) - syncDur := s.LastSync.Duration.AsDuration().Round(time.Millisecond) + 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) + } - if syncAge >= time.Minute*10 || syncDur >= 30*time.Second { + switch { + case syncAge >= 10*time.Minute, syncDur >= 30*time.Second: shortStatus = color.RedString(dot) - } else if syncAge >= time.Minute*1 || syncDur >= 20*time.Second { + case syncAge >= time.Minute, syncDur >= 20*time.Second, !allUp: shortStatus = color.YellowString(dot) - } else { + default: shortStatus = color.GreenString(dot) - if !allUp { - shortStatus = color.YellowString(dot) - } } - syncLast = humanizeDuration(syncAge) + " ago" - syncDurStr = fmt.Sprintf("%v", syncDur) + if syncAge > 0 { + syncLast = humanizeDuration(syncAge) + " ago" + } + if syncDur > 0 { + syncDurStr = fmt.Sprintf("%v", syncDur) + } } - // FIXME: nil pointer checks and refactor if s.LastSyncError != nil { - errorTime := s.LastSyncError.Time.AsTime() + 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)), s.LastSyncError.Error) + lastError = fmt.Sprintf("%s ago: %s", humanizeDuration(time.Since(errorTime)), error) - if errorTime.After(s.LastSync.Time.AsTime()) { + if errorTime.After(syncTime) { shortStatus = color.RedString(dot) } } From 7857b7b95a41cbfe8ea4459e40d3a8080efc8f56 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Mon, 26 Jan 2026 16:46:08 +0100 Subject: [PATCH 4/8] add switch functions --- cmd/admin/v2/switch.go | 445 ++++++++++++++++++++++++++++++- cmd/sorters/switch.go | 18 ++ cmd/tableprinters/switch.go | 226 ++++++++++++++++ cmd/tableprinters/switch_test.go | 91 +++++++ go.mod | 6 +- go.sum | 12 +- 6 files changed, 782 insertions(+), 16 deletions(-) create mode 100644 cmd/sorters/switch.go create mode 100644 cmd/tableprinters/switch_test.go diff --git a/cmd/admin/v2/switch.go b/cmd/admin/v2/switch.go index 9aaa4ee..881725b 100644 --- a/cmd/admin/v2/switch.go +++ b/cmd/admin/v2/switch.go @@ -1,13 +1,24 @@ 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 { @@ -56,15 +67,176 @@ func newSwitchCmd(c *config.Config) *cobra.Command { }, } - return genericcli.NewCmds(cmdsConfig) + 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("name", "", "Name 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("name", c.Completion.SwitchNameListCompletion)) + 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("name", "", "Name 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("name", c.Completion.SwitchNameListCompletion)) + 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.") + // TODO: implement Completion.SwitchListPorts + // 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) { - panic("unimplemented") + 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) { - panic("unimplemented") + 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) { @@ -72,13 +244,272 @@ func (c *switchCmd) Create(rq any) (*apiv2.Switch, error) { } func (c *switchCmd) Delete(id string) (*apiv2.Switch, error) { - panic("unimplemented") + 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) { - panic("unimplemented") + 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) Update(rq *adminv2.SwitchServiceUpdateRequest) (*apiv2.Switch, error) { - panic("unimplemented") +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 { + resp, err := c.List() + if err != nil { + return err + } + + var result []*tableprinters.SwitchDetail + for _, s := range resp { + result = append(result, &tableprinters.SwitchDetail{Switch: s}) + } + + return c.c.ListPrinter.Print(result) +} + +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/sorters/switch.go b/cmd/sorters/switch.go new file mode 100644 index 0000000..263c24e --- /dev/null +++ b/cmd/sorters/switch.go @@ -0,0 +1,18 @@ +package sorters + +import ( + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "github.com/metal-stack/metal-lib/pkg/multisort" +) + +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) + }, + // TODO: also allow sorting by partition, rack, os, status, last sync time, replace mode, metal-core version, ip + }, multisort.Keys{{ID: "id"}}) +} diff --git a/cmd/tableprinters/switch.go b/cmd/tableprinters/switch.go index 2ca9c27..ab5ee0f 100644 --- a/cmd/tableprinters/switch.go +++ b/cmd/tableprinters/switch.go @@ -2,12 +2,17 @@ package tableprinters import ( "fmt" + "regexp" + "sort" + "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" + "github.com/spf13/viper" ) func (t *TablePrinter) SwitchTable(switches []*apiv2.Switch, wide bool) ([]string, [][]string, error) { @@ -154,3 +159,224 @@ func (t *TablePrinter) SwitchTable(switches []*apiv2.Switch, wide bool) ([]strin 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) { + var ( + rows [][]string + ) + + header := []string{"ID", "NIC Name", "Identifier", "Partition", "Rack", "Size", "Product Serial", "Chassis Serial"} + if wide { + header = []string{"ID", "", "NIC Name", "Identifier", "Partition", "Rack", "Size", "Hostname", "Product Serial", "Chassis Serial"} + } + + t.t.DisableAutoWrap(true) + + for _, s := range data.Switches { + rack := pointer.SafeDeref(s.Rack) + + if wide { + rows = append(rows, []string{s.Id, "", "", "", s.Partition, rack}) + } else { + rows = append(rows, []string{s.Id, "", "", s.Partition, rack}) + } + + conns := s.MachineConnections + if viper.IsSet("size") || viper.IsSet("machine-id") { + filteredConns := []*apiv2.MachineConnection{} + + for _, conn := range s.MachineConnections { + m, ok := data.Machines[conn.MachineId] + if !ok { + continue + } + + if viper.IsSet("machine-id") && m.Uuid == viper.GetString("machine-id") { + filteredConns = append(filteredConns, conn) + } + + if viper.IsSet("size") && m.Size.Id == viper.GetString("size") { + filteredConns = append(filteredConns, conn) + } + } + + conns = filteredConns + } + + sort.Slice(conns, switchInterfaceNameLessFunc(conns)) + + for i, conn := range conns { + if conn == nil { + continue + } + + prefix := "├" + if i == len(conns)-1 { + prefix = "└" + } + prefix += "─╴" + + nic := pointer.SafeDeref(conn.Nic) + m, ok := data.Machines[conn.MachineId] + if !ok { + return nil, nil, fmt.Errorf("switch port %s is connected to a machine which does not exist: %q", nic.Name, conn.MachineId) + } + + identifier := nic.Identifier + if identifier == "" { + identifier = nic.Mac + } + + nicname := nic.Name + nicstate := pointer.SafeDeref(nic.State).Actual + bgpstate := pointer.SafeDeref(nic.BgpPortState) + if nicstate != apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_UP { + nicname = fmt.Sprintf("%s (%s)", nicname, color.RedString(nicstate.String())) + } + if wide { + switch bgpstate.BgpState { + case apiv2.BGPState_BGP_STATE_ESTABLISHED: + uptime := time.Since(time.Unix(pointer.SafeDeref(bgpstate.BgpTimerUpEstablished).Seconds, 0)) + nicname = fmt.Sprintf("%s (BGP:%s(%s))", nicname, bgpstate.BgpState, uptime) + default: + nicname = fmt.Sprintf("%s (BGP:%s)", nicname, bgpstate.BgpState) + } + } + + if wide { + // TODO: add emojis once machine functions are implemented + // emojis, _ := t.getMachineStatusEmojis(m.Liveliness, m.Events, m.State, pointer.SafeDeref(m.Allocation).Vpn) + + rows = append(rows, []string{ + fmt.Sprintf("%s%s", prefix, m.Uuid), + // emojis, + nicname, + identifier, + pointer.SafeDeref(m.Partition).Id, + m.Rack, + pointer.SafeDeref(m.Size).Id, + pointer.SafeDeref(m.Allocation).Hostname, + // TODO: where to get ipmi information? + // pointer.SafeDeref(pointer.SafeDeref(m.Ipmi).Fru).ProductSerial, + // pointer.SafeDeref(pointer.SafeDeref(m.Ipmi).Fru).ChassisPartSerial, + }) + } else { + rows = append(rows, []string{ + fmt.Sprintf("%s%s", prefix, m.Uuid), + nicname, + identifier, + pointer.SafeDeref(m.Partition).Id, + m.Rack, + pointer.SafeDeref(m.Size).Id, + // TODO: where to get ipmi information? + // pointer.SafeDeref(pointer.SafeDeref(m.Ipmi).Fru).ProductSerial, + // pointer.SafeDeref(pointer.SafeDeref(m.Ipmi).Fru).ChassisPartSerial, + }) + } + } + } + + return header, rows, nil +} + +var numberRegex = regexp.MustCompile("([0-9]+)") + +func switchInterfaceNameLessFunc(conns []*apiv2.MachineConnection) 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 { + *apiv2.Switch +} + +func (t *TablePrinter) SwitchDetailTable(data []*SwitchDetail, wide bool) ([]string, [][]string, error) { + var ( + header = []string{"Partition", "Rack", "Switch", "Port", "Machine", "VNI-Filter", "CIDR-Filter"} + rows [][]string + ) + + for _, sw := range data { + filterBySwp := map[string]*apiv2.BGPFilter{} + for _, nic := range sw.Nics { + if nic == nil { + continue + } + + if nic.BgpFilter != nil { + filterBySwp[nic.Name] = nic.BgpFilter + } + } + + for _, conn := range sw.MachineConnections { + if conn == nil { + continue + } + + nicName := pointer.SafeDeref(conn.Nic).Name + + f := filterBySwp[nicName] + row := []string{sw.Partition, pointer.SafeDeref(sw.Rack), sw.Id, nicName, conn.MachineId} + row = append(row, filterColumns(f, 0)...) + max := len(f.Vnis) + if len(f.Cidrs) > max { + max = len(f.Cidrs) + } + rows = append(rows, row) + for i := 1; i < max; i++ { + row = append([]string{"", "", "", "", ""}, filterColumns(f, i)...) + rows = append(rows, row) + } + } + } + + return header, rows, nil +} + +func filterColumns(filter *apiv2.BGPFilter, i int) []string { + if filter == nil { + return nil + } + + v := "" + if len(filter.Vnis) > i { + v = filter.Vnis[i] + } + c := "" + if len(filter.Cidrs) > i { + c = filter.Cidrs[i] + } + return []string{v, c} +} diff --git a/cmd/tableprinters/switch_test.go b/cmd/tableprinters/switch_test.go new file mode 100644 index 0000000..9bdbaaa --- /dev/null +++ b/cmd/tableprinters/switch_test.go @@ -0,0 +1,91 @@ +package tableprinters + +import ( + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" + "google.golang.org/protobuf/testing/protocmp" +) + +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) + } + }) + } +} diff --git a/go.mod b/go.mod index cd30b20..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.38-0.20260114100931-81fb1b7d4b93 + 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 @@ -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.2 // 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.32.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 87ad89c..e429bee 100644 --- a/go.sum +++ b/go.sum @@ -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.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +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,8 +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.38-0.20260114100931-81fb1b7d4b93 h1:GfDGUyn3KA7LI/NyO6smxFs2xkW6bKQxce77cDylgJ4= -github.com/metal-stack/api v0.0.38-0.20260114100931-81fb1b7d4b93/go.mod h1:lVDIha/gViLpYuJi+OhQIQCeh6XYdzGxrtbtJTJ94eI= +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= @@ -111,8 +111,8 @@ 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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +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= From 315bb960b9932d5617661b856552d9a3efb9532c Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Fri, 30 Jan 2026 14:19:48 +0100 Subject: [PATCH 5/8] test switch table --- cmd/admin/v2/switch.go | 12 +- cmd/tableprinters/common.go | 5 + cmd/tableprinters/switch.go | 183 ++++------------- cmd/tableprinters/switch_test.go | 324 +++++++++++++++++++++++++++++++ 4 files changed, 376 insertions(+), 148 deletions(-) diff --git a/cmd/admin/v2/switch.go b/cmd/admin/v2/switch.go index 881725b..d8c55be 100644 --- a/cmd/admin/v2/switch.go +++ b/cmd/admin/v2/switch.go @@ -378,17 +378,19 @@ func (c *switchCmd) switchConsole(args []string) error { } func (c *switchCmd) switchDetail() error { - resp, err := c.List() + switches, err := c.List() if err != nil { return err } - var result []*tableprinters.SwitchDetail - for _, s := range resp { - result = append(result, &tableprinters.SwitchDetail{Switch: s}) + var switchDetails []tableprinters.SwitchDetail + for _, sw := range switches { + switchDetails = append(switchDetails, tableprinters.SwitchDetail{ + Switch: sw, + }) } - return c.c.ListPrinter.Print(result) + return c.c.ListPrinter.Print(switchDetails) } func (c *switchCmd) switchMigrate(args []string) error { diff --git a/cmd/tableprinters/common.go b/cmd/tableprinters/common.go index 5a519ca..5171533 100644 --- a/cmd/tableprinters/common.go +++ b/cmd/tableprinters/common.go @@ -81,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, wide) + 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 index ab5ee0f..69ae623 100644 --- a/cmd/tableprinters/switch.go +++ b/cmd/tableprinters/switch.go @@ -2,8 +2,8 @@ package tableprinters import ( "fmt" + "math" "regexp" - "sort" "strconv" "strings" "time" @@ -12,7 +12,6 @@ import ( apiv2 "github.com/metal-stack/api/go/metalstack/api/v2" "github.com/metal-stack/metal-lib/pkg/pointer" - "github.com/spf13/viper" ) func (t *TablePrinter) SwitchTable(switches []*apiv2.Switch, wide bool) ([]string, [][]string, error) { @@ -23,7 +22,6 @@ func (t *TablePrinter) SwitchTable(switches []*apiv2.Switch, wide bool) ([]strin 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) } @@ -57,7 +55,7 @@ func (t *TablePrinter) SwitchTable(switches []*apiv2.Switch, wide bool) ([]strin 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, c.Nic.State.Actual, desired) + lastError = fmt.Sprintf("%q is %s but should be %s", c.Nic.Name, portStatusString(actual), portStatusString(*desired)) break } @@ -166,127 +164,12 @@ type SwitchesWithMachines struct { } 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", "Chassis Serial"} - if wide { - header = []string{"ID", "", "NIC Name", "Identifier", "Partition", "Rack", "Size", "Hostname", "Product Serial", "Chassis Serial"} - } - - t.t.DisableAutoWrap(true) - - for _, s := range data.Switches { - rack := pointer.SafeDeref(s.Rack) - - if wide { - rows = append(rows, []string{s.Id, "", "", "", s.Partition, rack}) - } else { - rows = append(rows, []string{s.Id, "", "", s.Partition, rack}) - } - - conns := s.MachineConnections - if viper.IsSet("size") || viper.IsSet("machine-id") { - filteredConns := []*apiv2.MachineConnection{} - - for _, conn := range s.MachineConnections { - m, ok := data.Machines[conn.MachineId] - if !ok { - continue - } - - if viper.IsSet("machine-id") && m.Uuid == viper.GetString("machine-id") { - filteredConns = append(filteredConns, conn) - } - - if viper.IsSet("size") && m.Size.Id == viper.GetString("size") { - filteredConns = append(filteredConns, conn) - } - } - - conns = filteredConns - } - - sort.Slice(conns, switchInterfaceNameLessFunc(conns)) - - for i, conn := range conns { - if conn == nil { - continue - } - - prefix := "├" - if i == len(conns)-1 { - prefix = "└" - } - prefix += "─╴" - - nic := pointer.SafeDeref(conn.Nic) - m, ok := data.Machines[conn.MachineId] - if !ok { - return nil, nil, fmt.Errorf("switch port %s is connected to a machine which does not exist: %q", nic.Name, conn.MachineId) - } - - identifier := nic.Identifier - if identifier == "" { - identifier = nic.Mac - } - - nicname := nic.Name - nicstate := pointer.SafeDeref(nic.State).Actual - bgpstate := pointer.SafeDeref(nic.BgpPortState) - if nicstate != apiv2.SwitchPortStatus_SWITCH_PORT_STATUS_UP { - nicname = fmt.Sprintf("%s (%s)", nicname, color.RedString(nicstate.String())) - } - if wide { - switch bgpstate.BgpState { - case apiv2.BGPState_BGP_STATE_ESTABLISHED: - uptime := time.Since(time.Unix(pointer.SafeDeref(bgpstate.BgpTimerUpEstablished).Seconds, 0)) - nicname = fmt.Sprintf("%s (BGP:%s(%s))", nicname, bgpstate.BgpState, uptime) - default: - nicname = fmt.Sprintf("%s (BGP:%s)", nicname, bgpstate.BgpState) - } - } - - if wide { - // TODO: add emojis once machine functions are implemented - // emojis, _ := t.getMachineStatusEmojis(m.Liveliness, m.Events, m.State, pointer.SafeDeref(m.Allocation).Vpn) - - rows = append(rows, []string{ - fmt.Sprintf("%s%s", prefix, m.Uuid), - // emojis, - nicname, - identifier, - pointer.SafeDeref(m.Partition).Id, - m.Rack, - pointer.SafeDeref(m.Size).Id, - pointer.SafeDeref(m.Allocation).Hostname, - // TODO: where to get ipmi information? - // pointer.SafeDeref(pointer.SafeDeref(m.Ipmi).Fru).ProductSerial, - // pointer.SafeDeref(pointer.SafeDeref(m.Ipmi).Fru).ChassisPartSerial, - }) - } else { - rows = append(rows, []string{ - fmt.Sprintf("%s%s", prefix, m.Uuid), - nicname, - identifier, - pointer.SafeDeref(m.Partition).Id, - m.Rack, - pointer.SafeDeref(m.Size).Id, - // TODO: where to get ipmi information? - // pointer.SafeDeref(pointer.SafeDeref(m.Ipmi).Fru).ProductSerial, - // pointer.SafeDeref(pointer.SafeDeref(m.Ipmi).Fru).ChassisPartSerial, - }) - } - } - } - - return header, rows, nil + panic("unimplemented") } -var numberRegex = regexp.MustCompile("([0-9]+)") - 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 @@ -322,41 +205,36 @@ type SwitchDetail struct { *apiv2.Switch } -func (t *TablePrinter) SwitchDetailTable(data []*SwitchDetail, wide bool) ([]string, [][]string, error) { +func (t *TablePrinter) SwitchDetailTable(switches []SwitchDetail, wide bool) ([]string, [][]string, error) { var ( header = []string{"Partition", "Rack", "Switch", "Port", "Machine", "VNI-Filter", "CIDR-Filter"} rows [][]string ) - for _, sw := range data { - filterBySwp := map[string]*apiv2.BGPFilter{} + for _, sw := range switches { + filterByNic := map[string]*apiv2.BGPFilter{} for _, nic := range sw.Nics { if nic == nil { continue } if nic.BgpFilter != nil { - filterBySwp[nic.Name] = nic.BgpFilter + filterByNic[nic.Name] = nic.BgpFilter } } for _, conn := range sw.MachineConnections { - if conn == nil { + if conn == nil || conn.Nic == nil { continue } - nicName := pointer.SafeDeref(conn.Nic).Name - - f := filterBySwp[nicName] - row := []string{sw.Partition, pointer.SafeDeref(sw.Rack), sw.Id, nicName, conn.MachineId} - row = append(row, filterColumns(f, 0)...) - max := len(f.Vnis) - if len(f.Cidrs) > max { - max = len(f.Cidrs) - } + 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) - for i := 1; i < max; i++ { - row = append([]string{"", "", "", "", ""}, filterColumns(f, i)...) + + 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) } } @@ -366,17 +244,36 @@ func (t *TablePrinter) SwitchDetailTable(data []*SwitchDetail, wide bool) ([]str } func filterColumns(filter *apiv2.BGPFilter, i int) []string { + var ( + vni string + cidr string + ) + if filter == nil { return nil } - v := "" if len(filter.Vnis) > i { - v = filter.Vnis[i] + vni = filter.Vnis[i] } - c := "" if len(filter.Cidrs) > i { - c = 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 "" } - return []string{v, c} } diff --git a/cmd/tableprinters/switch_test.go b/cmd/tableprinters/switch_test.go index 9bdbaaa..71bb1cb 100644 --- a/cmd/tableprinters/switch_test.go +++ b/cmd/tableprinters/switch_test.go @@ -1,12 +1,19 @@ 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) { @@ -89,3 +96,320 @@ func Test_switchInterfaceNameLessFunc(t *testing.T) { }) } } + +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) { + type fields struct { + t *printers.TablePrinter + } + type args struct { + switches []SwitchDetail + wide bool + } + tests := []struct { + name string + fields fields + args args + want []string + want1 [][]string + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tr := &TablePrinter{ + t: tt.fields.t, + } + got, got1, err := tr.SwitchDetailTable(tt.args.switches, tt.args.wide) + if (err != nil) != tt.wantErr { + t.Errorf("TablePrinter.SwitchDetailTable() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("TablePrinter.SwitchDetailTable() got = %v, want %v", got, tt.want) + } + if !reflect.DeepEqual(got1, tt.want1) { + t.Errorf("TablePrinter.SwitchDetailTable() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} From 5b24fe2623e39e00d9a18191e9fd636043677cc5 Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Fri, 30 Jan 2026 14:51:47 +0100 Subject: [PATCH 6/8] switch detail table test --- cmd/tableprinters/common.go | 2 +- cmd/tableprinters/switch.go | 6 +- cmd/tableprinters/switch_test.go | 135 +++++++++++++++++++++++++------ 3 files changed, 117 insertions(+), 26 deletions(-) diff --git a/cmd/tableprinters/common.go b/cmd/tableprinters/common.go index 5171533..9291220 100644 --- a/cmd/tableprinters/common.go +++ b/cmd/tableprinters/common.go @@ -84,7 +84,7 @@ func (t *TablePrinter) ToHeaderAndRows(data any, wide bool) ([]string, [][]strin case []*apiv2.Switch: return t.SwitchTable(d, wide) case []SwitchDetail: - return t.SwitchDetailTable(d, wide) + 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 index 69ae623..dcad06b 100644 --- a/cmd/tableprinters/switch.go +++ b/cmd/tableprinters/switch.go @@ -205,7 +205,7 @@ type SwitchDetail struct { *apiv2.Switch } -func (t *TablePrinter) SwitchDetailTable(switches []SwitchDetail, wide bool) ([]string, [][]string, error) { +func (t *TablePrinter) SwitchDetailTable(switches []SwitchDetail) ([]string, [][]string, error) { var ( header = []string{"Partition", "Rack", "Switch", "Port", "Machine", "VNI-Filter", "CIDR-Filter"} rows [][]string @@ -232,6 +232,10 @@ func (t *TablePrinter) SwitchDetailTable(switches []SwitchDetail, wide bool) ([] 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)...) diff --git a/cmd/tableprinters/switch_test.go b/cmd/tableprinters/switch_test.go index 71bb1cb..bb17aaf 100644 --- a/cmd/tableprinters/switch_test.go +++ b/cmd/tableprinters/switch_test.go @@ -377,38 +377,125 @@ func TestTablePrinter_SwitchTable(t *testing.T) { } func TestTablePrinter_SwitchDetailTable(t *testing.T) { - type fields struct { - t *printers.TablePrinter - } - type args struct { - switches []SwitchDetail - wide bool - } tests := []struct { - name string - fields fields - args args - want []string - want1 [][]string - wantErr bool + name string + switches []SwitchDetail + wantHeader []string + wantRows [][]string }{ - // TODO: Add test cases. + { + 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) { - tr := &TablePrinter{ - t: tt.fields.t, - } - got, got1, err := tr.SwitchDetailTable(tt.args.switches, tt.args.wide) - if (err != nil) != tt.wantErr { - t.Errorf("TablePrinter.SwitchDetailTable() error = %v, wantErr %v", err, tt.wantErr) + 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 !reflect.DeepEqual(got, tt.want) { - t.Errorf("TablePrinter.SwitchDetailTable() got = %v, want %v", got, tt.want) + if diff := cmp.Diff(tt.wantHeader, gotHeader); diff != "" { + t.Errorf("TablePrinter.SwitchDetailTable() diff header = %s", diff) } - if !reflect.DeepEqual(got1, tt.want1) { - t.Errorf("TablePrinter.SwitchDetailTable() got1 = %v, want %v", got1, tt.want1) + if diff := cmp.Diff(tt.wantRows, gotRows); diff != "" { + t.Errorf("TablePrinter.SwitchDetailTable() diff rows = %s", diff) } }) } From 56b7bbf4745e83911a8056691f5e1eb669a7d45e Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Fri, 30 Jan 2026 15:07:45 +0100 Subject: [PATCH 7/8] more sort keys for switch --- cmd/sorters/switch.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/cmd/sorters/switch.go b/cmd/sorters/switch.go index 263c24e..59985c2 100644 --- a/cmd/sorters/switch.go +++ b/cmd/sorters/switch.go @@ -3,6 +3,7 @@ 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] { @@ -13,6 +14,20 @@ func SwitchSorter() *multisort.Sorter[*apiv2.Switch] { "description": func(a, b *apiv2.Switch, descending bool) multisort.CompareResult { return multisort.Compare(a.Description, b.Description, descending) }, - // TODO: also allow sorting by partition, rack, os, status, last sync time, replace mode, metal-core version, ip + "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"}}) } From a23921e59929dfa35bc44028094f243266110dce Mon Sep 17 00:00:00 2001 From: Ilja Rotar Date: Fri, 30 Jan 2026 15:37:21 +0100 Subject: [PATCH 8/8] switch completions --- cmd/admin/v2/switch.go | 9 +---- cmd/completion/switch.go | 85 ++++++++++++++++++++++++++++++++++++---- cmd/completion/token.go | 2 - 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/cmd/admin/v2/switch.go b/cmd/admin/v2/switch.go index d8c55be..f367c68 100644 --- a/cmd/admin/v2/switch.go +++ b/cmd/admin/v2/switch.go @@ -49,14 +49,12 @@ func newSwitchCmd(c *config.Config) *cobra.Command { Sorter: &multisort.Sorter[*apiv2.Switch]{}, ListCmdMutateFn: func(cmd *cobra.Command) { cmd.Flags().String("id", "", "ID of the switch.") - cmd.Flags().String("name", "", "Name 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("name", c.Completion.SwitchNameListCompletion)) 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)) @@ -77,7 +75,6 @@ func newSwitchCmd(c *config.Config) *cobra.Command { } switchConnectedMachinesCmd.Flags().String("id", "", "ID of the switch.") - switchConnectedMachinesCmd.Flags().String("name", "", "Name 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.") @@ -88,7 +85,6 @@ func newSwitchCmd(c *config.Config) *cobra.Command { // 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("name", c.Completion.SwitchNameListCompletion)) genericcli.Must(switchConnectedMachinesCmd.RegisterFlagCompletionFunc("partition", c.Completion.PartitionListCompletion)) genericcli.Must(switchConnectedMachinesCmd.RegisterFlagCompletionFunc("rack", c.Completion.SwitchRackListCompletion)) @@ -116,14 +112,12 @@ func newSwitchCmd(c *config.Config) *cobra.Command { } switchDetailCmd.Flags().String("id", "", "ID of the switch.") - switchDetailCmd.Flags().String("name", "", "Name 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("name", c.Completion.SwitchNameListCompletion)) genericcli.Must(switchDetailCmd.RegisterFlagCompletionFunc("partition", c.Completion.PartitionListCompletion)) genericcli.Must(switchDetailCmd.RegisterFlagCompletionFunc("rack", c.Completion.SwitchRackListCompletion)) @@ -141,8 +135,7 @@ func newSwitchCmd(c *config.Config) *cobra.Command { Short: "sets the given switch port state up or down", } switchPortCmd.PersistentFlags().String("port", "", "the port to be changed.") - // TODO: implement Completion.SwitchListPorts - // genericcli.Must(switchPortCmd.RegisterFlagCompletionFunc("port", c.Completion.SwitchListPorts)) + genericcli.Must(switchPortCmd.RegisterFlagCompletionFunc("port", c.Completion.SwitchListPorts)) switchPortUpCmd := &cobra.Command{ Use: "up ", diff --git a/cmd/completion/switch.go b/cmd/completion/switch.go index bd87ca8..7183a10 100644 --- a/cmd/completion/switch.go +++ b/cmd/completion/switch.go @@ -1,29 +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) { - panic("unimplemented") -} + 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) + } -func (c *Completion) SwitchNameListCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - panic("unimplemented") + return ids, cobra.ShellCompDirectiveNoFileComp } func (c *Completion) PartitionListCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - panic("unimplemented") + 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) { - panic("unimplemented") + 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) { - panic("unimplemented") + 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) { - panic("unimplemented") + 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) }