diff --git a/cmd/metal-api/internal/datastore/switch.go b/cmd/metal-api/internal/datastore/switch.go index d5982c19..1f8b4e34 100644 --- a/cmd/metal-api/internal/datastore/switch.go +++ b/cmd/metal-api/internal/datastore/switch.go @@ -177,9 +177,21 @@ func (rs *RethinkStore) ConnectMachineWithSwitches(m *metal.Machine) error { if err != nil { return err } + // e.g. "swp1s0" -> "Ethernet0" + switchPortMapping, err := s1.MapPortNames(s2.OS.Vendor) + if err != nil { + return fmt.Errorf("could not create port mapping %w", err) + } + for _, con := range s1.MachineConnections[m.ID] { - if con2, has := byNicName[con.Nic.Name]; has { - if con.Nic.Name != con2.Nic.Name { + // get the corresponding interface name for s2 + name, ok := switchPortMapping[con.Nic.Name] + if !ok { + return fmt.Errorf("could not translate port name %s to equivalent port name of switch os %s", con.Nic.Name, s1.OS.Vendor) + } + // check if s2 contains nic of name corresponding to con.Nic.Name + if con2, has := byNicName[name]; has { + if name != con2.Nic.Name { return connectionMapError } } else { diff --git a/cmd/metal-api/internal/metal/switch.go b/cmd/metal-api/internal/metal/switch.go index f481192c..4d717d44 100644 --- a/cmd/metal-api/internal/metal/switch.go +++ b/cmd/metal-api/internal/metal/switch.go @@ -2,6 +2,8 @@ package metal import ( "fmt" + "strconv" + "strings" "time" ) @@ -24,11 +26,20 @@ type Switch struct { type Switches []Switch type SwitchOS struct { - Vendor string `rethinkdb:"vendor" json:"vendor"` - Version string `rethinkdb:"version" json:"version"` - MetalCoreVersion string `rethinkdb:"metal_core_version" json:"metal_core_version"` + Vendor SwitchOSVendor `rethinkdb:"vendor" json:"vendor"` + Version string `rethinkdb:"version" json:"version"` + MetalCoreVersion string `rethinkdb:"metal_core_version" json:"metal_core_version"` } +// SwitchOSVendor is an enum denoting the name of a switch OS +type SwitchOSVendor string + +// The enums for switch OS vendors +const ( + SwitchOSVendorSonic SwitchOSVendor = "SONiC" + SwitchOSVendorCumulus SwitchOSVendor = "Cumulus" +) + // Connection between switch port and machine. type Connection struct { Nic Nic `rethinkdb:"nic" json:"nic"` @@ -81,6 +92,15 @@ func SwitchModeFrom(name string) SwitchMode { } } +func ValidateSwitchOSVendor(os SwitchOSVendor) error { + switch os { + case SwitchOSVendorCumulus, SwitchOSVendorSonic: + return nil + default: + return fmt.Errorf("unknown switch os vendor %s", os) + } +} + // ByNicName builds a map of nic names to machine connection func (c ConnectionMap) ByNicName() (map[string]Connection, error) { res := make(map[string]Connection) @@ -144,3 +164,205 @@ func (s *Switch) SetVrfOfMachine(m *Machine, vrf string) { } s.Nics = nics } + +// TranslateNicMap creates a NicMap where the keys are translated to the naming convention of the target OS +// +// example mapping from cumulus to sonic for one single port: +// +// map[string]Nic { +// "swp1s1": Nic{ +// Name: "Ethernet1", +// MacAddress: "" +// } +// } +func (s *Switch) TranslateNicMap(targetOS SwitchOSVendor) (NicMap, error) { + nicMap := s.Nics.ByName() + translatedNicMap := make(NicMap) + + if s.OS.Vendor == targetOS { + return nicMap, nil + } + + ports := make([]string, 0) + for name := range nicMap { + ports = append(ports, name) + } + + lines, err := getLinesFromPortNames(ports, s.OS.Vendor) + if err != nil { + return nil, err + } + + for _, p := range ports { + targetPort, err := mapPortName(p, s.OS.Vendor, targetOS, lines) + if err != nil { + return nil, err + } + + nic, ok := nicMap[p] + if !ok { + return nil, fmt.Errorf("an unknown error occured during port name translation") + } + translatedNicMap[targetPort] = nic + } + + return translatedNicMap, nil +} + +// MapPortNames creates a dictionary that maps the naming convention of this switch's OS to that of the target OS +func (s *Switch) MapPortNames(targetOS SwitchOSVendor) (map[string]string, error) { + nics := s.Nics.ByName() + portNamesMap := make(map[string]string, len(s.Nics)) + + ports := make([]string, 0) + for name := range nics { + ports = append(ports, name) + } + + lines, err := getLinesFromPortNames(ports, s.OS.Vendor) + if err != nil { + return nil, err + } + + for _, p := range ports { + targetPort, err := mapPortName(p, s.OS.Vendor, targetOS, lines) + if err != nil { + return nil, err + } + portNamesMap[p] = targetPort + } + + return portNamesMap, nil +} + +func mapPortName(port string, sourceOS, targetOS SwitchOSVendor, allLines []int) (string, error) { + line, err := portNameToLine(port, sourceOS) + if err != nil { + return "", fmt.Errorf("unable to get line number from port name, %w", err) + } + + switch targetOS { + case SwitchOSVendorSonic: + return sonicPortByLineNumber(line), nil + case SwitchOSVendorCumulus: + return cumulusPortByLineNumber(line, allLines), nil + default: + return "", fmt.Errorf("unknown target switch os %s", targetOS) + } +} + +func getLinesFromPortNames(ports []string, os SwitchOSVendor) ([]int, error) { + lines := make([]int, 0) + for _, p := range ports { + l, err := portNameToLine(p, os) + if err != nil { + return nil, fmt.Errorf("unable to get line number from port name, %w", err) + } + + lines = append(lines, l) + } + + return lines, nil +} + +func portNameToLine(port string, os SwitchOSVendor) (int, error) { + switch os { + case SwitchOSVendorSonic: + return sonicPortNameToLine(port) + case SwitchOSVendorCumulus: + return cumulusPortNameToLine(port) + default: + return 0, fmt.Errorf("unknown switch os %s", os) + } + +} + +func sonicPortNameToLine(port string) (int, error) { + // to prevent accidentally parsing a substring to a negative number + if strings.Contains(port, "-") { + return 0, fmt.Errorf("invalid token '-' in port name %s", port) + } + + prefix, lineString, found := strings.Cut(port, "Ethernet") + if !found { + return 0, fmt.Errorf("invalid port name %s, expected to find prefix 'Ethernet'", port) + } + + if prefix != "" { + return 0, fmt.Errorf("invalid port name %s, port name is expected to start with 'Ethernet'", port) + } + + line, err := strconv.Atoi(lineString) + if err != nil { + return 0, fmt.Errorf("unable to convert port name to line number: %w", err) + } + + return line, nil +} + +func cumulusPortNameToLine(port string) (int, error) { + // to prevent accidentally parsing a substring to a negative number + if strings.Contains(port, "-") { + return 0, fmt.Errorf("invalid token '-' in port name %s", port) + } + + prefix, suffix, found := strings.Cut(port, "swp") + if !found { + return 0, fmt.Errorf("invalid port name %s, expected to find prefix 'swp'", port) + } + + if prefix != "" { + return 0, fmt.Errorf("invalid port name %s, port name is expected to start with 'swp'", port) + } + + var line int + + countString, indexString, found := strings.Cut(suffix, "s") + if !found { + count, err := strconv.Atoi(suffix) + if err != nil { + return 0, fmt.Errorf("unable to convert port name to line number: %w", err) + } + if count <= 0 { + return 0, fmt.Errorf("invalid port name %s would map to negative number", port) + } + line = (count - 1) * 4 + } else { + count, err := strconv.Atoi(countString) + if err != nil { + return 0, fmt.Errorf("unable to convert port name to line number: %w", err) + } + if count <= 0 { + return 0, fmt.Errorf("invalid port name %s would map to negative number", port) + } + + index, err := strconv.Atoi(indexString) + if err != nil { + return 0, fmt.Errorf("unable to convert port name to line number: %w", err) + } + line = (count-1)*4 + index + } + + return line, nil +} + +func sonicPortByLineNumber(line int) string { + return fmt.Sprintf("Ethernet%d", line) +} + +func cumulusPortByLineNumber(line int, allLines []int) string { + if line%4 > 0 { + return fmt.Sprintf("swp%ds%d", line/4+1, line%4) + } + + for _, l := range allLines { + if l == line { + continue + } + if l/4 == line/4 { + return fmt.Sprintf("swp%ds%d", line/4+1, line%4) + } + } + + return fmt.Sprintf("swp%d", line/4+1) +} diff --git a/cmd/metal-api/internal/metal/switch_test.go b/cmd/metal-api/internal/metal/switch_test.go index 434a4ae2..bc7d3cba 100644 --- a/cmd/metal-api/internal/metal/switch_test.go +++ b/cmd/metal-api/internal/metal/switch_test.go @@ -1,10 +1,13 @@ package metal import ( + "fmt" "reflect" + "strconv" "testing" "github.com/google/go-cmp/cmp" + "github.com/metal-stack/metal-lib/pkg/testcommon" ) var ( @@ -280,6 +283,490 @@ func TestSwitch_ConnectMachine2(t *testing.T) { } } +func TestSwitch_TranslateNicMap(t *testing.T) { + tests := []struct { + name string + sw *Switch + targetOS SwitchOSVendor + want NicMap + wantErr bool + }{ + { + name: "both twins have the same os", + sw: &Switch{ + Nics: []Nic{ + {Name: "swp1s0"}, + {Name: "swp1s1"}, + {Name: "swp1s2"}, + {Name: "swp1s3"}, + }, + OS: &SwitchOS{Vendor: SwitchOSVendorCumulus}, + }, + targetOS: SwitchOSVendorCumulus, + want: map[string]*Nic{ + "swp1s0": {Name: "swp1s0"}, + "swp1s1": {Name: "swp1s1"}, + "swp1s2": {Name: "swp1s2"}, + "swp1s3": {Name: "swp1s3"}, + }, + wantErr: false, + }, + { + name: "cumulus to sonic", + sw: &Switch{ + Nics: []Nic{ + {Name: "Ethernet1"}, + {Name: "Ethernet2"}, + {Name: "Ethernet3"}, + {Name: "Ethernet4"}, + }, + OS: &SwitchOS{Vendor: SwitchOSVendorSonic}, + }, + targetOS: SwitchOSVendorCumulus, + want: map[string]*Nic{ + "swp1s1": {Name: "Ethernet1"}, + "swp1s2": {Name: "Ethernet2"}, + "swp1s3": {Name: "Ethernet3"}, + "swp2": {Name: "Ethernet4"}, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.sw.TranslateNicMap(tt.targetOS) + if (err != nil) != tt.wantErr { + t.Errorf("translateNicNames() error = %v, wantErr %v", err, tt.wantErr) + return + } + if cmp.Diff(got, tt.want) != "" { + t.Errorf("translateNicNames() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSwitch_MapPortNames(t *testing.T) { + tests := []struct { + name string + sw *Switch + targetOS SwitchOSVendor + want map[string]string + wantErr bool + }{ + { + name: "same os", + sw: &Switch{ + Nics: []Nic{ + {Name: "swp1s0"}, + {Name: "swp1s1"}, + {Name: "swp1s2"}, + {Name: "swp1s3"}, + }, + OS: &SwitchOS{Vendor: SwitchOSVendorCumulus}, + }, + targetOS: SwitchOSVendorCumulus, + want: map[string]string{ + "swp1s0": "swp1s0", + "swp1s1": "swp1s1", + "swp1s2": "swp1s2", + "swp1s3": "swp1s3", + }, + wantErr: false, + }, + { + name: "cumulus to sonic", + sw: &Switch{ + Nics: []Nic{ + {Name: "swp1s0"}, + {Name: "swp2s0"}, + {Name: "swp2s1"}, + {Name: "swp2s2"}, + }, + OS: &SwitchOS{Vendor: SwitchOSVendorCumulus}, + }, + targetOS: SwitchOSVendorSonic, + want: map[string]string{ + "swp1s0": "Ethernet0", + "swp2s0": "Ethernet4", + "swp2s1": "Ethernet5", + "swp2s2": "Ethernet6", + }, + wantErr: false, + }, + { + name: "sonic to cumulus", + sw: &Switch{ + Nics: []Nic{ + {Name: "Ethernet0"}, + {Name: "Ethernet4"}, + {Name: "Ethernet8"}, + {Name: "Ethernet9"}, + }, + OS: &SwitchOS{Vendor: SwitchOSVendorSonic}, + }, + targetOS: SwitchOSVendorCumulus, + want: map[string]string{ + "Ethernet0": "swp1", + "Ethernet4": "swp2", + "Ethernet8": "swp3s0", + "Ethernet9": "swp3s1", + }, + wantErr: false, + }, + { + name: "sonic names in cumulus switch", + sw: &Switch{ + Nics: []Nic{ + {Name: "Ethernet0"}, + {Name: "Ethernet4"}, + {Name: "Ethernet8"}, + {Name: "Ethernet9"}, + }, + OS: &SwitchOS{Vendor: SwitchOSVendorCumulus}, + }, + targetOS: SwitchOSVendorSonic, + want: nil, + wantErr: true, + }, + { + name: "cumulus names in sonic switch", + sw: &Switch{ + Nics: []Nic{ + {Name: "swp1s0"}, + {Name: "swp1s1"}, + {Name: "swp1s2"}, + {Name: "swp1s3"}, + }, + OS: &SwitchOS{Vendor: SwitchOSVendorSonic}, + }, + targetOS: SwitchOSVendorCumulus, + want: nil, + wantErr: true, + }, + { + name: "invalid name", + sw: &Switch{ + Nics: []Nic{ + {Name: "swp1s"}, + }, + OS: &SwitchOS{Vendor: SwitchOSVendorSonic}, + }, + targetOS: SwitchOSVendorCumulus, + want: nil, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.sw.MapPortNames(tt.targetOS) + if (err != nil) != tt.wantErr { + t.Errorf("Switch.MapPortNames() error = %v, wantErr %v", err, tt.wantErr) + return + } + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Errorf("%v", diff) + } + }) + } +} + +func Test_mapPortName(t *testing.T) { + tests := []struct { + name string + port string + sourceOS SwitchOSVendor + targetOS SwitchOSVendor + allLines []int + want string + wantErr error + }{ + { + name: "invalid target os", + port: "Ethernet0", + sourceOS: SwitchOSVendorSonic, + targetOS: "cumulus", + allLines: []int{0, 1}, + want: "", + wantErr: fmt.Errorf("unknown target switch os cumulus"), + }, + { + name: "sonic to cumulus", + port: "Ethernet11", + sourceOS: SwitchOSVendorSonic, + targetOS: SwitchOSVendorCumulus, + allLines: []int{11}, + want: "swp3s3", + wantErr: nil, + }, + { + name: "cumulus to sonic", + port: "swp4s0", + sourceOS: SwitchOSVendorCumulus, + targetOS: SwitchOSVendorSonic, + allLines: []int{0, 4, 12, 13}, + want: "Ethernet12", + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := mapPortName(tt.port, tt.sourceOS, tt.targetOS, tt.allLines) + if diff := cmp.Diff(err, tt.wantErr, testcommon.ErrorStringComparer()); diff != "" { + t.Errorf("MapPortName() error diff: %s", diff) + return + } + if got != tt.want { + t.Errorf("MapPortName() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_getLinesFromPortNames(t *testing.T) { + tests := []struct { + name string + ports []string + os SwitchOSVendor + want []int + wantErr bool + }{ + { + name: "invalid switch os", + ports: []string{"swp1", "swp1s2"}, + os: "cumulus", + want: nil, + wantErr: true, + }, + { + name: "mismatch between port names and os cumulus", + ports: []string{"Ethernet0", "Ethernet1"}, + os: SwitchOSVendorCumulus, + want: nil, + wantErr: true, + }, + { + name: "mismatch between port names and os sonic", + ports: []string{"swp1s0", "swp1s1"}, + os: SwitchOSVendorSonic, + want: nil, + wantErr: true, + }, + { + name: "sonic conversion successful", + ports: []string{"Ethernet0", "Ethernet2"}, + os: SwitchOSVendorSonic, + want: []int{0, 2}, + wantErr: false, + }, + { + name: "cumulus conversion successful", + ports: []string{"swp1", "swp2s3"}, + os: SwitchOSVendorCumulus, + want: []int{0, 7}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getLinesFromPortNames(tt.ports, tt.os) + if (err != nil) != tt.wantErr { + t.Errorf("GetLinesFromPortNames() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetLinesFromPortNames() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_sonicPortNameToLine(t *testing.T) { + _, parseIntError := strconv.Atoi("_1") + + tests := []struct { + name string + port string + want int + wantErr error + }{ + { + name: "invalid token", + port: "Ethernet-0", + want: 0, + wantErr: fmt.Errorf("invalid token '-' in port name Ethernet-0"), + }, + { + name: "missing prefix 'Ethernet'", + port: "swp1s0", + want: 0, + wantErr: fmt.Errorf("invalid port name swp1s0, expected to find prefix 'Ethernet'"), + }, + { + name: "invalid prefix before 'Ethernet'", + port: "port_Ethernet0", + want: 0, + wantErr: fmt.Errorf("invalid port name port_Ethernet0, port name is expected to start with 'Ethernet'"), + }, + { + name: "cannot convert line number", + port: "Ethernet_1", + want: 0, + wantErr: fmt.Errorf("unable to convert port name to line number: %w", parseIntError), + }, + { + name: "conversion successful", + port: "Ethernet25", + want: 25, + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := sonicPortNameToLine(tt.port) + if diff := cmp.Diff(err, tt.wantErr, testcommon.ErrorStringComparer()); diff != "" { + t.Errorf("sonicPortNameToLine() error diff: %s", diff) + return + } + if got != tt.want { + t.Errorf("sonicPortNameToLine() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_cumulusPortNameToLine(t *testing.T) { + _, parseIntError1 := strconv.Atoi("1t0") + _, parseIntError2 := strconv.Atoi("_0") + + tests := []struct { + name string + port string + want int + wantErr error + }{ + { + name: "invalid token", + port: "swp-0s1", + want: 0, + wantErr: fmt.Errorf("invalid token '-' in port name swp-0s1"), + }, + { + name: "missing prefix 'swp'", + port: "Ethernet0", + want: 0, + wantErr: fmt.Errorf("invalid port name Ethernet0, expected to find prefix 'swp'"), + }, + { + name: "invalid prefix before 'swp'", + port: "port_swp1s0", + want: 0, + wantErr: fmt.Errorf("invalid port name port_swp1s0, port name is expected to start with 'swp'"), + }, + { + name: "wrong delimiter", + port: "swp1t0", + want: 0, + wantErr: fmt.Errorf("unable to convert port name to line number: %w", parseIntError1), + }, + { + name: "cannot convert first number", + port: "swp_0s0", + want: 0, + wantErr: fmt.Errorf("unable to convert port name to line number: %w", parseIntError2), + }, + { + name: "cannot convert second number", + port: "swp1s_0", + want: 0, + wantErr: fmt.Errorf("unable to convert port name to line number: %w", parseIntError2), + }, + { + name: "cannot convert swp0 because that would result in a negative line number", + port: "swp0", + want: 0, + wantErr: fmt.Errorf("invalid port name swp0 would map to negative number"), + }, + { + name: "cannot convert swp0s1 because that would result in a negative line number", + port: "swp0s1", + want: 0, + wantErr: fmt.Errorf("invalid port name swp0s1 would map to negative number"), + }, + { + name: "convert line without breakout", + port: "swp4", + want: 12, + wantErr: nil, + }, + { + name: "convert line with breakout", + port: "swp3s3", + want: 11, + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := cumulusPortNameToLine(tt.port) + if diff := cmp.Diff(err, tt.wantErr, testcommon.ErrorStringComparer()); diff != "" { + t.Errorf("cumulusPortNameToLine() error diff: %s", diff) + return + } + if got != tt.want { + t.Errorf("cumulusPortNameToLine() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_cumulusPortByLineNumber(t *testing.T) { + tests := []struct { + name string + line int + allLines []int + want string + }{ + { + name: "only one line", + line: 4, + allLines: []int{4}, + want: "swp2", + }, + { + name: "line number 0 without breakout", + line: 0, + allLines: []int{0, 4}, + want: "swp1", + }, + { + name: "higher line number without breakout", + line: 4, + allLines: []int{0, 1, 2, 3, 4, 8}, + want: "swp2", + }, + { + name: "line number divisible by 4 with breakout", + line: 4, + allLines: []int{4, 5, 6, 7}, + want: "swp2s0", + }, + { + name: "line number not divisible by 4", + line: 13, + allLines: []int{13}, + want: "swp4s1", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := cumulusPortByLineNumber(tt.line, tt.allLines); got != tt.want { + t.Errorf("cumulusPortByLineNumber() = %v, want %v", got, tt.want) + } + }) + } +} + func TestConnectionMap_ByNicName(t *testing.T) { tests := []struct { name string diff --git a/cmd/metal-api/internal/service/integration_test.go b/cmd/metal-api/internal/service/integration_test.go index d462d066..1f7cd742 100644 --- a/cmd/metal-api/internal/service/integration_test.go +++ b/cmd/metal-api/internal/service/integration_test.go @@ -221,6 +221,7 @@ func createTestEnvironment(t *testing.T, log *slog.Logger, ds *datastore.Rethink }, SwitchBase: v1.SwitchBase{ RackID: "test-rack", + OS: &v1.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, }, Nics: v1.SwitchNics{ { @@ -249,6 +250,7 @@ func createTestEnvironment(t *testing.T, log *slog.Logger, ds *datastore.Rethink }, SwitchBase: v1.SwitchBase{ RackID: "test-rack", + OS: &v1.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, }, Nics: v1.SwitchNics{ { diff --git a/cmd/metal-api/internal/service/machine-service_allocation_test.go b/cmd/metal-api/internal/service/machine-service_allocation_test.go index ffde006e..4c600fd7 100644 --- a/cmd/metal-api/internal/service/machine-service_allocation_test.go +++ b/cmd/metal-api/internal/service/machine-service_allocation_test.go @@ -89,7 +89,7 @@ func TestMachineAllocationIntegration(t *testing.T) { e, _ := errgroup.WithContext(context.Background()) for i := range machineCount { e.Go(func() error { - mr := createMachineRegisterRequest(i) + mr := createMachineRegisterRequest(i + 1) err := retry.Do( func() error { var err2 error @@ -286,7 +286,7 @@ func createMachineRegisterRequest(i int) *grpcv1.BootServiceRegisterRequest { Mac: fmt.Sprintf("aa:ba:%d", i), Neighbors: []*grpcv1.MachineNic{ { - Name: fmt.Sprintf("swp-%d", i), + Name: fmt.Sprintf("swp%d", i), Mac: fmt.Sprintf("%s:%d", swp1MacPrefix, i), }, }, @@ -296,7 +296,7 @@ func createMachineRegisterRequest(i int) *grpcv1.BootServiceRegisterRequest { Mac: fmt.Sprintf("aa:bb:%d", i), Neighbors: []*grpcv1.MachineNic{ { - Name: fmt.Sprintf("swp-%d", i), + Name: fmt.Sprintf("swp%d", i), Mac: fmt.Sprintf("%s:%d", swp2MacPrefix, i), }, }, @@ -369,7 +369,7 @@ func setupTestEnvironment(machineCount int, t *testing.T, ds *datastore.RethinkS func createTestdata(machineCount int, rs *datastore.RethinkStore, ipamer ipam.IPAMer, t *testing.T) { for i := range machineCount { - id := fmt.Sprintf("WaitingMachine%d", i) + id := fmt.Sprintf("WaitingMachine%d", i+1) m := &metal.Machine{ Base: metal.Base{ID: id}, SizeID: "s1", @@ -411,19 +411,19 @@ func createTestdata(machineCount int, rs *datastore.RethinkStore, ipamer ipam.IP sw2nics := metal.Nics{} for j := range machineCount { sw1nic := metal.Nic{ - Name: fmt.Sprintf("swp-%d", j), - MacAddress: metal.MacAddress(fmt.Sprintf("%s:%d", swp1MacPrefix, j)), + Name: fmt.Sprintf("swp%d", j+1), + MacAddress: metal.MacAddress(fmt.Sprintf("%s:%d", swp1MacPrefix, j+1)), } sw2nic := metal.Nic{ - Name: fmt.Sprintf("swp-%d", j), - MacAddress: metal.MacAddress(fmt.Sprintf("%s:%d", swp2MacPrefix, j)), + Name: fmt.Sprintf("swp%d", j+1), + MacAddress: metal.MacAddress(fmt.Sprintf("%s:%d", swp2MacPrefix, j+1)), } sw1nics = append(sw1nics, sw1nic) sw2nics = append(sw2nics, sw2nic) } - err = rs.CreateSwitch(&metal.Switch{Base: metal.Base{ID: "sw1"}, PartitionID: "p1", Nics: sw1nics, MachineConnections: metal.ConnectionMap{}}) + err = rs.CreateSwitch(&metal.Switch{Base: metal.Base{ID: "sw1"}, OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, PartitionID: "p1", Nics: sw1nics, MachineConnections: metal.ConnectionMap{}}) require.NoError(t, err) - err = rs.CreateSwitch(&metal.Switch{Base: metal.Base{ID: "sw2"}, PartitionID: "p1", Nics: sw2nics, MachineConnections: metal.ConnectionMap{}}) + err = rs.CreateSwitch(&metal.Switch{Base: metal.Base{ID: "sw2"}, OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, PartitionID: "p1", Nics: sw2nics, MachineConnections: metal.ConnectionMap{}}) require.NoError(t, err) err = rs.CreateFilesystemLayout(&metal.FilesystemLayout{Base: metal.Base{ID: "fsl1"}, Constraints: metal.FilesystemLayoutConstraints{Sizes: []string{"s1"}, Images: map[string]string{"i": "*"}}}) require.NoError(t, err) diff --git a/cmd/metal-api/internal/service/switch-service.go b/cmd/metal-api/internal/service/switch-service.go index c7c53fb4..2ef392ce 100644 --- a/cmd/metal-api/internal/service/switch-service.go +++ b/cmd/metal-api/internal/service/switch-service.go @@ -132,6 +132,15 @@ func (r *switchResource) webService() *restful.WebService { Returns(http.StatusOK, "OK", v1.SwitchNotifyResponse{}). DefaultReturns("Error", httperrors.HTTPErrorResponse{})) + ws.Route(ws.POST("/migrate"). + To(admin(r.migrate)). + Doc("migrates machine connections from one switch to another"). + Operation("migrateSwitch"). + Metadata(restfulspec.KeyOpenAPITags, tags). + Reads(v1.SwitchMigrateRequest{}). + Returns(http.StatusOK, "OK", v1.SwitchResponse{}). + DefaultReturns("Error", httperrors.HTTPErrorResponse{})) + return ws } @@ -479,6 +488,11 @@ func (r *switchResource) registerSwitch(request *restful.Request, response *rest return } + if err := metal.ValidateSwitchOSVendor(s.OS.Vendor); err != nil { + r.sendError(request, response, defaultError(err)) + return + } + err = r.ds.CreateSwitch(s) if err != nil { r.sendError(request, response, defaultError(err)) @@ -488,6 +502,12 @@ func (r *switchResource) registerSwitch(request *restful.Request, response *rest returnCode = http.StatusCreated } else if s.Mode == metal.SwitchReplace { spec := v1.NewSwitch(requestPayload) + + if err := metal.ValidateSwitchOSVendor(spec.OS.Vendor); err != nil { + r.sendError(request, response, defaultError(err)) + return + } + err = r.replaceSwitch(s, spec) if err != nil { r.sendError(request, response, defaultError(err)) @@ -499,6 +519,11 @@ func (r *switchResource) registerSwitch(request *restful.Request, response *rest old := *s spec := v1.NewSwitch(requestPayload) + if err := metal.ValidateSwitchOSVendor(spec.OS.Vendor); err != nil { + r.sendError(request, response, defaultError(err)) + return + } + uniqueNewNics := spec.Nics.ByIdentifier() if len(requestPayload.Nics) != len(uniqueNewNics) { r.sendError(request, response, httperrors.BadRequest(errors.New("duplicate identifier found in nics"))) @@ -562,6 +587,78 @@ func (r *switchResource) registerSwitch(request *restful.Request, response *rest r.send(request, response, returnCode, resp) } +func (r *switchResource) migrate(request *restful.Request, response *restful.Response) { + var requestPayload v1.SwitchMigrateRequest + err := request.ReadEntity(&requestPayload) + if err != nil { + r.sendError(request, response, httperrors.BadRequest(err)) + return + } + + if requestPayload.OldSwitchID == "" { + r.sendError(request, response, httperrors.BadRequest(errors.New("old switch id cannot be empty"))) + return + } + + if requestPayload.NewSwitchID == "" { + r.sendError(request, response, httperrors.BadRequest(errors.New("new switch id cannot be empty"))) + return + } + + old, err := r.ds.FindSwitch(requestPayload.OldSwitchID) + if err != nil { + r.sendError(request, response, defaultError(err)) + return + } + + new, err := r.ds.FindSwitch(requestPayload.NewSwitchID) + if err != nil { + r.sendError(request, response, defaultError(err)) + return + } + + if err := metal.ValidateSwitchOSVendor(new.OS.Vendor); err != nil { + r.sendError(request, response, defaultError(err)) + return + } + + if old.RackID != new.RackID { + r.sendError(request, response, httperrors.BadRequest(fmt.Errorf("new switch must be in the same rack as the old one"))) + return + } + + if len(new.MachineConnections) > 0 { + r.sendError(request, response, httperrors.BadRequest(fmt.Errorf("target switch already has machine connections"))) + return + } + + s, err := adoptConfiguration(old, new) + if err != nil { + r.sendError(request, response, defaultError(err)) + return + } + + err = r.migrateMachineConnections(old, s) + if err != nil { + r.sendError(request, response, defaultError(err)) + return + } + + err = r.ds.UpdateSwitch(new, s) + if err != nil { + r.sendError(request, response, defaultError(fmt.Errorf("failed to migrate switch %s to %s but partial changes might have been written to the database. undo partial changes by migrating in the opposite direction. %w", old.ID, new.ID, err))) + return + } + + resp, err := r.makeSwitchResponse(s) + if err != nil { + r.sendError(request, response, defaultError(err)) + return + } + + r.send(request, response, http.StatusOK, resp) +} + // replaceSwitch replaces a broken switch // // assumptions: @@ -585,6 +682,15 @@ func (r *switchResource) replaceSwitch(old, new *metal.Switch) error { return err } + nicMap, err := s.TranslateNicMap(old.OS.Vendor) + if err != nil { + return err + } + err = r.adjustMachineConnections(old.MachineConnections, nicMap) + if err != nil { + return err + } + return r.ds.UpdateSwitch(old, s) } @@ -609,6 +715,9 @@ func (r *switchResource) findTwinSwitch(newSwitch *metal.Switch) (*metal.Switch, if sw.Mode == metal.SwitchReplace || sw.ID == newSwitch.ID { continue } + if len(sw.MachineConnections) == 0 { + continue + } if twin == nil { twin = &sw } else { @@ -624,8 +733,6 @@ func (r *switchResource) findTwinSwitch(newSwitch *metal.Switch) (*metal.Switch, // adoptFromTwin adopts the switch configuration found at the neighboring twin switch to a replacement switch. func adoptFromTwin(old, twin, new *metal.Switch) (*metal.Switch, error) { - s := *new - if new.PartitionID != old.PartitionID { return nil, fmt.Errorf("old and new switch belong to different partitions, old: %v, new: %v", old.PartitionID, new.PartitionID) } @@ -637,16 +744,22 @@ func adoptFromTwin(old, twin, new *metal.Switch) (*metal.Switch, error) { } if len(twin.MachineConnections) == 0 { // twin switch has no machine connections, switch may be used immediately, replace mode is unnecessary + s := *new s.Mode = metal.SwitchOperational return &s, nil } - newNics, err := adoptNics(twin, new) + return adoptConfiguration(twin, new) +} + +func adoptConfiguration(existing, new *metal.Switch) (*metal.Switch, error) { + s := *new + newNics, err := adoptNics(existing, new) if err != nil { - return nil, fmt.Errorf("could not adopt nic configuration from twin, err: %w", err) + return nil, fmt.Errorf("could not adopt existing nic configuration, err: %w", err) } - newMachineConnections, err := adoptMachineConnections(twin, new) + newMachineConnections, err := adoptMachineConnections(existing, new) if err != nil { return nil, err } @@ -662,9 +775,13 @@ func adoptFromTwin(old, twin, new *metal.Switch) (*metal.Switch, error) { // copies vrf configuration and returns the new nics for the replacement switch func adoptNics(twin, newSwitch *metal.Switch) (metal.Nics, error) { newNics := metal.Nics{} - newNicMap := newSwitch.Nics.ByName() + newNicMap, err := newSwitch.TranslateNicMap(twin.OS.Vendor) + if err != nil { + return nil, err + } missingNics := []string{} twinNicsByName := twin.Nics.ByName() + for name := range twinNicsByName { if _, ok := newNicMap[name]; !ok { missingNics = append(missingNics, name) @@ -675,15 +792,12 @@ func adoptNics(twin, newSwitch *metal.Switch) (metal.Nics, error) { } for name, nic := range newNicMap { + newNic := *nic // check for configuration at twin if twinNic, ok := twinNicsByName[name]; ok { - newNic := *nic newNic.Vrf = twinNic.Vrf - newNics = append(newNics, newNic) - } else { - // leave unchanged - newNics = append(newNics, *nic) } + newNics = append(newNics, newNic) } sort.SliceStable(newNics, func(i, j int) bool { @@ -694,7 +808,10 @@ func adoptNics(twin, newSwitch *metal.Switch) (metal.Nics, error) { // adoptMachineConnections copies machine connections from twin and maps mac addresses based on the nic name func adoptMachineConnections(twin, newSwitch *metal.Switch) (metal.ConnectionMap, error) { - newNicMap := newSwitch.Nics.ByName() + newNicMap, err := newSwitch.TranslateNicMap(twin.OS.Vendor) + if err != nil { + return nil, err + } newConnectionMap := metal.ConnectionMap{} missingNics := []string{} @@ -703,6 +820,7 @@ func adoptMachineConnections(twin, newSwitch *metal.Switch) (metal.ConnectionMap for _, con := range cons { if n, ok := newNicMap[con.Nic.Name]; ok { newCon := con + newCon.Nic.Name = n.Name newCon.Nic.Identifier = n.Identifier newCon.Nic.MacAddress = n.MacAddress newConnections = append(newConnections, newCon) @@ -721,6 +839,81 @@ func adoptMachineConnections(twin, newSwitch *metal.Switch) (metal.ConnectionMap return newConnectionMap, nil } +// migrateMachineConnections removes all machine connections from the old switch and adds them to the new one. this enables switch deletion after migration is completed. +func (r *switchResource) migrateMachineConnections(old, new *metal.Switch) error { + nicMap, err := new.TranslateNicMap(old.OS.Vendor) + if err != nil { + return err + } + + err = r.adjustMachineConnections(old.MachineConnections, nicMap) + if err != nil { + return err + } + + return r.removeSwitchMachineConnections(old) +} + +func (r *switchResource) removeSwitchMachineConnections(sw *metal.Switch) error { + new := *sw + new.MachineConnections = make(metal.ConnectionMap) + return r.ds.UpdateSwitch(sw, &new) +} + +// adjustMachineConnections updates the neighbor entries for all machines connected to the switch +func (r *switchResource) adjustMachineConnections(oldConnections metal.ConnectionMap, nicMap metal.NicMap) error { + for mid, cons := range oldConnections { + m, err := r.ds.FindMachineByID(mid) + if err != nil { + return err + } + newNics, err := adjustMachineNics(m.Hardware.Nics, cons, nicMap) + if err != nil { + return err + } + newMachine := *m + newMachine.Hardware.Nics = newNics + err = r.ds.UpdateMachine(m, &newMachine) + if err != nil { + return err + } + } + return nil +} + +func adjustMachineNics(nics metal.Nics, connections metal.Connections, nicMap metal.NicMap) (metal.Nics, error) { + newNics := make(metal.Nics, 0) + + for _, nic := range nics { + newNic := nic + newNeighbors := make([]metal.Nic, 0) + for _, neigh := range nic.Neighbors { + if nicInConnections(neigh.Name, neigh.MacAddress, connections) { + n, ok := nicMap[neigh.Name] + if !ok { + return nil, fmt.Errorf("unable to find corresponding new neighbor nic") + } + newNeighbors = append(newNeighbors, *n) + } else { + newNeighbors = append(newNeighbors, neigh) + } + } + newNic.Neighbors = newNeighbors + newNics = append(newNics, newNic) + } + + return newNics, nil +} + +func nicInConnections(name string, mac metal.MacAddress, connections metal.Connections) bool { + for _, con := range connections { + if con.Nic.Name == name && con.Nic.MacAddress == mac { + return true + } + } + return false +} + func updateSwitchNics(oldNics, newNics map[string]*metal.Nic, currentConnections metal.ConnectionMap) (metal.Nics, error) { // To start off we just prevent basic things that can go wrong nicsThatGetLost := metal.Nics{} diff --git a/cmd/metal-api/internal/service/switch-service_integration_test.go b/cmd/metal-api/internal/service/switch-service_integration_test.go new file mode 100644 index 00000000..6d6e1e0e --- /dev/null +++ b/cmd/metal-api/internal/service/switch-service_integration_test.go @@ -0,0 +1,760 @@ +//go:build integration +// +build integration + +package service + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/emicklei/go-restful/v3" + mdmv1 "github.com/metal-stack/masterdata-api/api/v1" + mdmv1mock "github.com/metal-stack/masterdata-api/api/v1/mocks" + mdm "github.com/metal-stack/masterdata-api/pkg/client" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/datastore" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/ipam" + "github.com/metal-stack/metal-api/cmd/metal-api/internal/metal" + v1 "github.com/metal-stack/metal-api/cmd/metal-api/internal/service/v1" + "github.com/metal-stack/metal-api/test" + "github.com/metal-stack/metal-lib/bus" + "github.com/metal-stack/security" + testifymock "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" +) + +func TestSwitchMigrateIntegration(t *testing.T) { + ts := createTestService(t) + defer ts.terminate() + + testPartitionID := "test-partition" + testRackID := "test-rack" + + cumulus1 := metal.Switch{ + Base: metal.Base{ + ID: "test-switch01", + Name: "", + }, + Nics: []metal.Nic{ + { + MacAddress: "aa:aa:aa:aa:aa:aa", + Name: "swp1", + }, + }, + PartitionID: testPartitionID, + RackID: testRackID, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, + } + + cumulus2 := metal.Switch{ + Base: metal.Base{ + ID: "test-switch02", + Name: "", + }, + Nics: []metal.Nic{ + { + MacAddress: "bb:bb:bb:bb:bb:bb", + Name: "swp1", + }, + }, + PartitionID: testPartitionID, + RackID: testRackID, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, + } + + m := &metal.Machine{ + Base: metal.Base{ + ID: "test-machine", + }, + PartitionID: testPartitionID, + RackID: testRackID, + Hardware: metal.MachineHardware{ + Nics: []metal.Nic{ + { + Name: "eth0", + MacAddress: "11:11:11:11:11:11", + Neighbors: []metal.Nic{ + { + Name: cumulus1.Nics[0].Name, + MacAddress: cumulus1.Nics[0].MacAddress, + }, + }, + }, + { + Name: "eth1", + MacAddress: "22:22:22:22:22:22", + Neighbors: []metal.Nic{ + { + Name: cumulus2.Nics[0].Name, + MacAddress: cumulus2.Nics[0].MacAddress, + }, + }, + }, + }, + }, + } + + sonic1 := metal.Switch{ + Base: metal.Base{ + ID: "test-switch01-sonic", + }, + Nics: []metal.Nic{ + { + MacAddress: "cc:cc:cc:cc:cc:cc", + Name: "Ethernet0", + }, + }, + PartitionID: testPartitionID, + RackID: testRackID, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorSonic}, + } + + wantConnections1 := metal.ConnectionMap{ + m.ID: metal.Connections{ + { + Nic: metal.Nic{ + Name: sonic1.Nics[0].Name, + MacAddress: sonic1.Nics[0].MacAddress, + }, + MachineID: m.ID, + }, + }, + } + + wantMachineNics1 := metal.Nics{ + { + Name: m.Hardware.Nics[0].Name, + MacAddress: m.Hardware.Nics[0].MacAddress, + Neighbors: []metal.Nic{ + { + Name: sonic1.Nics[0].Name, + MacAddress: sonic1.Nics[0].MacAddress, + }, + }, + }, + { + Name: m.Hardware.Nics[1].Name, + MacAddress: m.Hardware.Nics[1].MacAddress, + Neighbors: []metal.Nic{ + { + Name: cumulus2.Nics[0].Name, + MacAddress: cumulus2.Nics[0].MacAddress, + }, + }, + }, + } + + sonic2 := metal.Switch{ + Base: metal.Base{ + ID: "test-switch02-sonic", + }, + Nics: []metal.Nic{ + { + MacAddress: "dd:dd:dd:dd:dd:dd", + Name: "Ethernet0", + }, + }, + PartitionID: testPartitionID, + RackID: testRackID, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorSonic}, + } + + wantConnections2 := metal.ConnectionMap{ + m.ID: metal.Connections{ + { + Nic: metal.Nic{ + Name: sonic2.Nics[0].Name, + MacAddress: sonic2.Nics[0].MacAddress, + }, + MachineID: m.ID, + }, + }, + } + + wantMachineNics2 := metal.Nics{ + { + Name: m.Hardware.Nics[0].Name, + MacAddress: m.Hardware.Nics[0].MacAddress, + Neighbors: []metal.Nic{ + { + Name: sonic1.Nics[0].Name, + MacAddress: sonic1.Nics[0].MacAddress, + }, + }, + }, + { + Name: m.Hardware.Nics[1].Name, + MacAddress: m.Hardware.Nics[1].MacAddress, + Neighbors: []metal.Nic{ + { + Name: sonic2.Nics[0].Name, + MacAddress: sonic2.Nics[0].MacAddress, + }, + }, + }, + } + + ts.createPartition(testPartitionID, testPartitionID, "Test Partition") + + ts.registerSwitch(cumulus1, true) + ts.registerSwitch(cumulus2, true) + ts.createMachine(m) + + ts.registerSwitch(sonic1, true) + ts.registerSwitch(sonic2, true) + + ts.migrateSwitch(cumulus1, sonic1, wantConnections1) + ts.checkMachineNics(m.ID, wantMachineNics1) + ts.migrateSwitch(cumulus2, sonic2, wantConnections2) + ts.checkMachineNics(m.ID, wantMachineNics2) +} + +func TestSwitchReplaceIntegration(t *testing.T) { + ts := createTestService(t) + defer ts.terminate() + + testPartitionID := "test-partition" + testRackID := "test-rack" + + cumulus1 := metal.Switch{ + Base: metal.Base{ + ID: "test-switch01", + Name: "", + }, + Nics: []metal.Nic{ + { + MacAddress: "aa:aa:aa:aa:aa:aa", + Name: "swp1", + }, + }, + PartitionID: testPartitionID, + RackID: testRackID, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, + } + + cumulus2 := metal.Switch{ + Base: metal.Base{ + ID: "test-switch02", + Name: "", + }, + Nics: []metal.Nic{ + { + MacAddress: "bb:bb:bb:bb:bb:bb", + Name: "swp1", + }, + }, + PartitionID: testPartitionID, + RackID: testRackID, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, + } + + m := &metal.Machine{ + Base: metal.Base{ + ID: "test-machine", + }, + PartitionID: testPartitionID, + RackID: testRackID, + Hardware: metal.MachineHardware{ + Nics: []metal.Nic{ + { + Name: "eth0", + MacAddress: "11:11:11:11:11:11", + Neighbors: []metal.Nic{ + { + Name: cumulus1.Nics[0].Name, + MacAddress: cumulus1.Nics[0].MacAddress, + }, + }, + }, + { + Name: "eth1", + MacAddress: "22:22:22:22:22:22", + Neighbors: []metal.Nic{ + { + Name: cumulus2.Nics[0].Name, + MacAddress: cumulus2.Nics[0].MacAddress, + }, + }, + }, + }, + }, + } + + cumulus3 := metal.Switch{ + Base: metal.Base{ + ID: "test-switch01", + }, + Nics: []metal.Nic{ + { + MacAddress: "cc:cc:cc:cc:cc:cc", + Name: "swp1", + }, + }, + PartitionID: testPartitionID, + RackID: testRackID, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, + } + + wantConnections1 := metal.ConnectionMap{ + m.ID: metal.Connections{ + { + Nic: metal.Nic{ + Name: cumulus3.Nics[0].Name, + MacAddress: cumulus3.Nics[0].MacAddress, + }, + MachineID: m.ID, + }, + }, + } + + wantMachineNics1 := metal.Nics{ + { + Name: m.Hardware.Nics[0].Name, + MacAddress: m.Hardware.Nics[0].MacAddress, + Neighbors: []metal.Nic{ + { + Name: cumulus3.Nics[0].Name, + MacAddress: cumulus3.Nics[0].MacAddress, + }, + }, + }, + { + Name: m.Hardware.Nics[1].Name, + MacAddress: m.Hardware.Nics[1].MacAddress, + Neighbors: []metal.Nic{ + { + Name: cumulus2.Nics[0].Name, + MacAddress: cumulus2.Nics[0].MacAddress, + }, + }, + }, + } + + sonic1 := metal.Switch{ + Base: metal.Base{ + ID: "test-switch02", + }, + Nics: []metal.Nic{ + { + MacAddress: "dd:dd:dd:dd:dd:dd", + Name: "Ethernet0", + }, + }, + PartitionID: testPartitionID, + RackID: testRackID, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorSonic}, + } + + wantConnections2 := metal.ConnectionMap{ + m.ID: metal.Connections{ + { + Nic: metal.Nic{ + Name: sonic1.Nics[0].Name, + MacAddress: sonic1.Nics[0].MacAddress, + }, + MachineID: m.ID, + }, + }, + } + + wantMachineNics2 := metal.Nics{ + { + Name: m.Hardware.Nics[0].Name, + MacAddress: m.Hardware.Nics[0].MacAddress, + Neighbors: []metal.Nic{ + { + Name: cumulus3.Nics[0].Name, + MacAddress: cumulus3.Nics[0].MacAddress, + }, + }, + }, + { + Name: m.Hardware.Nics[1].Name, + MacAddress: m.Hardware.Nics[1].MacAddress, + Neighbors: []metal.Nic{ + { + Name: sonic1.Nics[0].Name, + MacAddress: sonic1.Nics[0].MacAddress, + }, + }, + }, + } + + idleSwitch1 := metal.Switch{ + Base: metal.Base{ + ID: "idle-switch01", + }, + Nics: []metal.Nic{ + { + MacAddress: "ee:ee:ee:ee:ee:ee", + Name: "Ethernet0", + }, + }, + PartitionID: testPartitionID, + RackID: testRackID, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorSonic}, + } + + idleSwitch2 := metal.Switch{ + Base: metal.Base{ + ID: "idle-switch02", + }, + Nics: []metal.Nic{ + { + MacAddress: "ff:ff:ff:ff:ff:ff", + Name: "Ethernet0", + }, + }, + PartitionID: testPartitionID, + RackID: testRackID, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorSonic}, + } + + ts.createPartition(testPartitionID, testPartitionID, "Test Partition") + + ts.registerSwitch(cumulus1, true) + ts.registerSwitch(cumulus2, true) + ts.createMachine(m) + + // register idle switches to test if we still find the correct twin + ts.registerSwitch(idleSwitch1, true) + ts.registerSwitch(idleSwitch2, true) + + ts.replaceSwitch(cumulus3, wantConnections1) + ts.checkMachineNics(m.ID, wantMachineNics1) + + ts.replaceSwitch(sonic1, wantConnections2) + ts.checkMachineNics(m.ID, wantMachineNics2) +} + +type testService struct { + partitionService *restful.WebService + switchService *restful.WebService + machineService *restful.WebService + ds *datastore.RethinkStore + rethinkContainer testcontainers.Container + ctx context.Context + t *testing.T +} + +func (ts *testService) terminate() { + _ = ts.rethinkContainer.Terminate(ts.ctx) +} + +func createTestService(t *testing.T) testService { + ipamer := ipam.InitTestIpam(t) + rethinkContainer, c, err := test.StartRethink(t) + require.NoError(t, err) + + log := slog.Default() + ds := datastore.New(log, c.IP+":"+c.Port, c.DB, c.User, c.Password) + ds.VRFPoolRangeMax = 1000 + ds.ASNPoolRangeMax = 1000 + + err = ds.Connect() + require.NoError(t, err) + err = ds.Initialize() + require.NoError(t, err) + + psc := &mdmv1mock.ProjectServiceClient{} + psc.On("Get", testifymock.Anything, &mdmv1.ProjectGetRequest{Id: "test-project-1"}).Return(&mdmv1.ProjectResponse{Project: &mdmv1.Project{ + Meta: &mdmv1.Meta{ + Id: "test-project-1", + }, + }}, nil) + psc.On("Find", testifymock.Anything, &mdmv1.ProjectFindRequest{}).Return(&mdmv1.ProjectListResponse{Projects: []*mdmv1.Project{ + {Meta: &mdmv1.Meta{Id: "test-project-1"}}, + }}, nil) + mdc := mdm.NewMock(psc, nil, nil, nil) + + hma := security.NewHMACAuth(testUserDirectory.admin.Name, []byte{1, 2, 3}, security.WithUser(testUserDirectory.admin)) + usergetter := security.NewCreds(security.WithHMAC(hma)) + machineService, err := NewMachine(log, ds, &emptyPublisher{}, bus.DirectEndpoints(), ipamer, mdc, nil, usergetter, 0, nil, metal.DisabledIPMISuperUser()) + require.NoError(t, err) + switchService := NewSwitch(log, ds) + require.NoError(t, err) + partitionService := NewPartition(log, ds, &emptyPublisher{}) + require.NoError(t, err) + + ts := testService{ + partitionService: partitionService, + switchService: switchService, + machineService: machineService, + ds: ds, + rethinkContainer: rethinkContainer, + ctx: context.TODO(), + t: t, + } + return ts +} + +func (ts *testService) partitionCreate(t *testing.T, icr v1.PartitionCreateRequest, response interface{}) int { + return webRequestPut(t, ts.partitionService, &testUserDirectory.admin, icr, "/v1/partition/", response) +} + +func (ts *testService) switchRegister(t *testing.T, srr v1.SwitchRegisterRequest, response interface{}) int { + return webRequestPost(t, ts.switchService, &testUserDirectory.admin, srr, "/v1/switch/register", response) +} + +func (ts *testService) switchGet(t *testing.T, swid string, response interface{}) int { + return webRequestGet(t, ts.switchService, &testUserDirectory.admin, emptyBody{}, "/v1/switch/"+swid, response) +} + +func (ts *testService) switchUpdate(t *testing.T, sur v1.SwitchUpdateRequest, response interface{}) int { + return webRequestPost(t, ts.switchService, &testUserDirectory.admin, sur, "/v1/switch/", response) +} + +func (ts *testService) machineGet(t *testing.T, mid string, response interface{}) int { + return webRequestGet(t, ts.machineService, &testUserDirectory.admin, emptyBody{}, "/v1/machine/"+mid, response) +} + +func (ts *testService) switchMigrate(t *testing.T, smr v1.SwitchMigrateRequest, response interface{}) int { + return webRequestPost(t, ts.switchService, &testUserDirectory.admin, smr, "/v1/switch/migrate", response) +} + +func (ts *testService) switchDelete(t *testing.T, sid string, response interface{}) int { + return webRequestDelete(t, ts.switchService, &testUserDirectory.admin, emptyBody{}, "/v1/switch/"+sid, response) +} + +func (ts *testService) createPartition(id, name, description string) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "I am a downloadable content") + })) + defer s.Close() + + downloadableFile := s.URL + partitionName := name + partitionDesc := description + partition := v1.PartitionCreateRequest{ + Common: v1.Common{ + Identifiable: v1.Identifiable{ + ID: id, + }, + Describable: v1.Describable{ + Name: &partitionName, + Description: &partitionDesc, + }, + }, + PartitionBootConfiguration: v1.PartitionBootConfiguration{ + ImageURL: &downloadableFile, + KernelURL: &downloadableFile, + }, + } + var createdPartition v1.PartitionResponse + status := ts.partitionCreate(ts.t, partition, &createdPartition) + require.Equal(ts.t, http.StatusCreated, status) + require.NotNil(ts.t, createdPartition) + require.Equal(ts.t, partition.Name, createdPartition.Name) + require.NotEmpty(ts.t, createdPartition.ID) + +} + +func (ts *testService) registerSwitch(sw metal.Switch, isNewId bool) { + nics := make([]v1.SwitchNic, 0) + for _, nic := range sw.Nics { + nic := v1.SwitchNic{ + MacAddress: string(nic.MacAddress), + Name: nic.Name, + } + nics = append(nics, nic) + } + + srr := v1.SwitchRegisterRequest{ + Common: v1.Common{ + Identifiable: v1.Identifiable{ + ID: sw.ID, + }, + }, + Nics: nics, + PartitionID: sw.PartitionID, + SwitchBase: v1.SwitchBase{ + RackID: sw.RackID, + OS: &v1.SwitchOS{ + Vendor: sw.OS.Vendor, + }, + }, + } + + wantStatus := http.StatusOK + if isNewId { + wantStatus = http.StatusCreated + } + var res v1.SwitchResponse + status := ts.switchRegister(ts.t, srr, &res) + require.Equal(ts.t, wantStatus, status) + ts.checkSwitchResponse(sw, &res) +} + +func (ts *testService) createMachine(m *metal.Machine) { + err := ts.ds.CreateMachine(m) + require.NoError(ts.t, err) + err = ts.ds.ConnectMachineWithSwitches(m) + require.NoError(ts.t, err) + + err = ts.ds.CreateProvisioningEventContainer(&metal.ProvisioningEventContainer{ + Base: metal.Base{ID: m.ID}, + Liveliness: metal.MachineLivelinessAlive, + }) + require.NoError(ts.t, err) +} + +func (ts *testService) migrateSwitch(oldSwitch, newSwitch metal.Switch, wantConnections metal.ConnectionMap) { + wantSwitch := newSwitch + wantSwitch.Mode = metal.SwitchOperational + wantSwitch.MachineConnections = wantConnections + + smr := v1.SwitchMigrateRequest{ + OldSwitchID: oldSwitch.ID, + NewSwitchID: newSwitch.ID, + } + + var res v1.SwitchResponse + ts.switchMigrate(ts.t, smr, &res) + status := ts.switchGet(ts.t, wantSwitch.ID, &res) + require.Equal(ts.t, http.StatusOK, status) + ts.checkSwitchResponse(wantSwitch, &res) + + status = ts.switchDelete(ts.t, oldSwitch.ID, &res) + require.Equal(ts.t, http.StatusOK, status) +} + +func (ts *testService) replaceSwitch(newSwitch metal.Switch, wantConnections metal.ConnectionMap) { + ts.setReplaceMode(newSwitch.ID) + ts.registerSwitch(newSwitch, false) + wantSwitch := newSwitch + wantSwitch.Mode = metal.SwitchOperational + wantSwitch.MachineConnections = wantConnections + + var res v1.SwitchResponse + status := ts.switchGet(ts.t, wantSwitch.ID, &res) + require.Equal(ts.t, http.StatusOK, status) + ts.checkSwitchResponse(wantSwitch, &res) +} + +func (ts *testService) setReplaceMode(id string) { + var res v1.SwitchResponse + + sur := v1.SwitchUpdateRequest{ + Common: v1.Common{ + Identifiable: v1.Identifiable{ + ID: id, + }, + }, + SwitchBase: v1.SwitchBase{ + Mode: string(metal.SwitchReplace), + }, + } + + status := ts.switchUpdate(ts.t, sur, &res) + require.Equal(ts.t, http.StatusOK, status) + require.Equal(ts.t, string(metal.SwitchReplace), res.SwitchBase.Mode) +} + +func (ts *testService) checkSwitchResponse(sw metal.Switch, res *v1.SwitchResponse) { + require.NotNil(ts.t, res) + require.Equal(ts.t, sw.Mode, metal.SwitchMode(res.Mode)) + + require.Len(ts.t, res.Nics, len(sw.Nics)) + for _, nic := range sw.Nics { + n := findNicByNameInSwitchNics(nic.Name, res.Nics) + ts.checkCorrectNic(n, nic) + } + + require.Len(ts.t, res.Connections, len(sw.MachineConnections)) + connectionsByNicName, err := sw.MachineConnections.ByNicName() + require.NoError(ts.t, err) + for nicName, con := range connectionsByNicName { + c, found := findMachineConnection(nicName, res.Connections) + require.True(ts.t, found) + require.Equal(ts.t, con.MachineID, c.MachineID) + require.Equal(ts.t, con.Nic.Name, c.Nic.Name) + require.Equal(ts.t, con.Nic.MacAddress, c.Nic.MacAddress) + } +} + +func (ts *testService) checkMachineNics(mid string, wantNics metal.Nics) { + var m v1.MachineResponse + status := ts.machineGet(ts.t, mid, &m) + require.Equal(ts.t, http.StatusOK, status) + require.NotNil(ts.t, m) + + for _, wantNic := range wantNics { + nic := findNicByNameInMachineNics(wantNic.Name, m.Hardware.Nics) + ts.checkCorrectNic(nic, wantNic) + for _, wantNeigh := range wantNic.Neighbors { + neigh := findNicByName(wantNeigh.Name, nic.Neighbors) + ts.checkCorrectNic(neigh, wantNeigh) + } + } +} + +func (ts *testService) checkCorrectNic(nic *metal.Nic, wantNic metal.Nic) { + require.NotNil(ts.t, wantNic) + require.Equal(ts.t, wantNic.Name, nic.Name) + require.Equal(ts.t, wantNic.MacAddress, nic.MacAddress) +} + +func findNicByName(name string, nics metal.Nics) *metal.Nic { + for _, nic := range nics { + n := nic + if nic.Name == name { + return &n + } + } + return nil +} + +func findNicByNameInSwitchNics(name string, nics v1.SwitchNics) *metal.Nic { + for _, nic := range nics { + if nic.Name == name { + n := &metal.Nic{ + Name: nic.Name, + MacAddress: metal.MacAddress(nic.MacAddress), + } + return n + } + } + return nil +} + +func findNicByNameInMachineNics(name string, nics v1.MachineNics) *metal.Nic { + for _, nic := range nics { + if nic.Name == name { + neighbors := make(metal.Nics, 0) + for _, neigh := range nic.Neighbors { + n := metal.Nic{ + Name: neigh.Name, + MacAddress: metal.MacAddress(neigh.MacAddress), + } + neighbors = append(neighbors, n) + } + n := &metal.Nic{ + Name: nic.Name, + MacAddress: metal.MacAddress(nic.MacAddress), + Neighbors: neighbors, + } + return n + } + } + return nil +} + +func findMachineConnection(nicName string, connections []v1.SwitchConnection) (*metal.Connection, bool) { + for _, con := range connections { + if con.Nic.Name == nicName { + c := &metal.Connection{ + Nic: metal.Nic{ + Name: con.Nic.Name, + MacAddress: metal.MacAddress(con.Nic.MacAddress), + }, + MachineID: con.MachineID, + } + return c, true + } + } + return nil, false +} diff --git a/cmd/metal-api/internal/service/switch-service_test.go b/cmd/metal-api/internal/service/switch-service_test.go index 149aa26a..8e7f833a 100644 --- a/cmd/metal-api/internal/service/switch-service_test.go +++ b/cmd/metal-api/internal/service/switch-service_test.go @@ -47,6 +47,7 @@ func TestRegisterSwitch(t *testing.T) { PartitionID: "1", SwitchBase: v1.SwitchBase{ RackID: "1", + OS: &v1.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, }, } js, err := json.Marshal(createRequest) @@ -89,6 +90,7 @@ func TestRegisterExistingSwitch(t *testing.T) { PartitionID: testdata.Switch2.PartitionID, SwitchBase: v1.SwitchBase{ RackID: testdata.Switch2.RackID, + OS: &v1.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, }, } js, err := json.Marshal(createRequest) @@ -134,6 +136,7 @@ func TestRegisterExistingSwitchErrorModifyingNics(t *testing.T) { PartitionID: testdata.Switch1.PartitionID, SwitchBase: v1.SwitchBase{ RackID: testdata.Switch1.RackID, + OS: &v1.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, }, } js, err := json.Marshal(createRequest) @@ -163,6 +166,7 @@ func TestReplaceSwitch(t *testing.T) { PartitionID: testdata.Switch2.PartitionID, SwitchBase: v1.SwitchBase{ RackID: testdata.Switch2.RackID, + OS: &v1.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, }, } js, err := json.Marshal(createRequest) @@ -188,6 +192,68 @@ func TestReplaceSwitch(t *testing.T) { require.Empty(t, result.Connections) } +func TestSwitchMigrateConnectionsExistError(t *testing.T) { + ds, mock := datastore.InitMockDB(t) + testdata.InitMockDBData(mock) + log := slog.Default() + + switchservice := NewSwitch(log, ds) + container := restful.NewContainer().Add(switchservice) + + migrateRequest := v1.SwitchMigrateRequest{ + OldSwitchID: testdata.Switch2.ID, + NewSwitchID: testdata.Switch1.ID, + } + js, err := json.Marshal(migrateRequest) + require.NoError(t, err) + body := bytes.NewBuffer(js) + req := httptest.NewRequest("POST", "/v1/switch/migrate", body) + req.Header.Add("Content-Type", "application/json") + container = injectAdmin(log, container, req) + w := httptest.NewRecorder() + container.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode, w.Body.String()) + var errorResponse httperrors.HTTPErrorResponse + err = json.NewDecoder(resp.Body).Decode(&errorResponse) + require.NoError(t, err) + require.Equal(t, "target switch already has machine connections", errorResponse.Message) + require.Equal(t, http.StatusBadRequest, errorResponse.StatusCode) +} + +func TestSwitchMigrateDifferentRacksError(t *testing.T) { + ds, mock := datastore.InitMockDB(t) + testdata.InitMockDBData(mock) + log := slog.Default() + + switchservice := NewSwitch(log, ds) + container := restful.NewContainer().Add(switchservice) + + migrateRequest := v1.SwitchMigrateRequest{ + OldSwitchID: testdata.Switch1.ID, + NewSwitchID: testdata.Switch3.ID, + } + js, err := json.Marshal(migrateRequest) + require.NoError(t, err) + body := bytes.NewBuffer(js) + req := httptest.NewRequest("POST", "/v1/switch/migrate", body) + req.Header.Add("Content-Type", "application/json") + container = injectAdmin(log, container, req) + w := httptest.NewRecorder() + container.ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode, w.Body.String()) + var errorResponse httperrors.HTTPErrorResponse + err = json.NewDecoder(resp.Body).Decode(&errorResponse) + require.NoError(t, err) + require.Equal(t, "new switch must be in the same rack as the old one", errorResponse.Message) + require.Equal(t, http.StatusBadRequest, errorResponse.StatusCode) +} + func TestConnectMachineWithSwitches(t *testing.T) { partitionID := "1" s1swp1 := metal.Nic{ @@ -197,6 +263,7 @@ func TestConnectMachineWithSwitches(t *testing.T) { s1 := metal.Switch{ Base: metal.Base{ID: "1"}, PartitionID: partitionID, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, MachineConnections: metal.ConnectionMap{}, Nics: metal.Nics{ s1swp1, @@ -209,6 +276,7 @@ func TestConnectMachineWithSwitches(t *testing.T) { s2 := metal.Switch{ Base: metal.Base{ID: "2"}, PartitionID: partitionID, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, MachineConnections: metal.ConnectionMap{}, Nics: metal.Nics{ s2swp1, @@ -662,6 +730,7 @@ func Test_adoptFromTwin(t *testing.T) { Mode: metal.SwitchReplace, }, twin: &metal.Switch{ + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, Nics: metal.Nics{ metal.Nic{ Name: "swp1s0", @@ -697,6 +766,7 @@ func Test_adoptFromTwin(t *testing.T) { }, }, newSwitch: &metal.Switch{ + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, Nics: metal.Nics{ metal.Nic{ Name: "swp1s0", @@ -719,6 +789,7 @@ func Test_adoptFromTwin(t *testing.T) { }, want: &metal.Switch{ Mode: metal.SwitchOperational, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, Nics: metal.Nics{ metal.Nic{ Name: "swp1s0", @@ -816,6 +887,7 @@ func Test_adoptFromTwin(t *testing.T) { RackID: "1", }, twin: &metal.Switch{ + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, PartitionID: "1", RackID: "1", }, @@ -831,6 +903,122 @@ func Test_adoptFromTwin(t *testing.T) { }, wantErr: false, }, + { + name: "adopt machine connections and nic configuration from twin with different switch os", + args: args{ + old: &metal.Switch{ + OS: &metal.SwitchOS{ + Vendor: metal.SwitchOSVendorCumulus, + }, + Mode: metal.SwitchReplace, + }, + twin: &metal.Switch{ + OS: &metal.SwitchOS{ + Vendor: metal.SwitchOSVendorCumulus, + }, + Nics: metal.Nics{ + metal.Nic{ + Name: "swp1s0", + MacAddress: "aa:aa:aa:aa:aa:a1", + Vrf: "1", + }, + metal.Nic{ + Name: "swp1s1", + MacAddress: "aa:aa:aa:aa:aa:a2", + }, + metal.Nic{ + Name: "swp1s2", + MacAddress: "aa:aa:aa:aa:aa:a3", + }, + }, + MachineConnections: metal.ConnectionMap{ + "m1": metal.Connections{ + metal.Connection{ + Nic: metal.Nic{ + Name: "swp1s0", + MacAddress: "aa:aa:aa:aa:aa:a1", + }, + }, + }, + "fw1": metal.Connections{ + metal.Connection{ + Nic: metal.Nic{ + Name: "swp1s1", + MacAddress: "aa:aa:aa:aa:aa:a2", + }, + }, + }, + }, + }, + newSwitch: &metal.Switch{ + OS: &metal.SwitchOS{ + Vendor: metal.SwitchOSVendorSonic, + }, + Nics: metal.Nics{ + metal.Nic{ + Name: "Ethernet0", + MacAddress: "bb:bb:bb:bb:bb:b1", + }, + metal.Nic{ + Name: "Ethernet1", + MacAddress: "bb:bb:bb:bb:bb:b2", + }, + metal.Nic{ + Name: "Ethernet2", + MacAddress: "bb:bb:bb:bb:bb:b3", + }, + metal.Nic{ + Name: "Ethernet3", + MacAddress: "bb:bb:bb:bb:bb:b4", + }, + }, + }, + }, + want: &metal.Switch{ + Mode: metal.SwitchOperational, + OS: &metal.SwitchOS{ + Vendor: metal.SwitchOSVendorSonic, + }, + Nics: metal.Nics{ + metal.Nic{ + Name: "Ethernet0", + MacAddress: "bb:bb:bb:bb:bb:b1", + Vrf: "1", + }, + metal.Nic{ + Name: "Ethernet1", + MacAddress: "bb:bb:bb:bb:bb:b2", + }, + metal.Nic{ + Name: "Ethernet2", + MacAddress: "bb:bb:bb:bb:bb:b3", + }, + metal.Nic{ + Name: "Ethernet3", + MacAddress: "bb:bb:bb:bb:bb:b4", + }, + }, + MachineConnections: metal.ConnectionMap{ + "m1": metal.Connections{ + metal.Connection{ + Nic: metal.Nic{ + Name: "Ethernet0", + MacAddress: "bb:bb:bb:bb:bb:b1", + }, + }, + }, + "fw1": metal.Connections{ + metal.Connection{ + Nic: metal.Nic{ + Name: "Ethernet1", + MacAddress: "bb:bb:bb:bb:bb:b2", + }, + }, + }, + }, + }, + wantErr: false, + }, } for i := range tests { @@ -848,7 +1036,7 @@ func Test_adoptFromTwin(t *testing.T) { } } -func Test_adoptNicsFromTwin(t *testing.T) { +func Test_adoptNics(t *testing.T) { type args struct { twin *metal.Switch newSwitch *metal.Switch @@ -863,6 +1051,7 @@ func Test_adoptNicsFromTwin(t *testing.T) { name: "adopt vrf configuration, leaf underlay ports untouched, newSwitch might have additional ports", args: args{ twin: &metal.Switch{ + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, Nics: metal.Nics{ metal.Nic{ Name: "swp1s0", @@ -877,6 +1066,7 @@ func Test_adoptNicsFromTwin(t *testing.T) { }, }, newSwitch: &metal.Switch{ + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, Nics: metal.Nics{ metal.Nic{ Name: "swp1s0", @@ -915,6 +1105,7 @@ func Test_adoptNicsFromTwin(t *testing.T) { name: "new switch misses nic", args: args{ twin: &metal.Switch{ + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, Nics: metal.Nics{ metal.Nic{ Name: "swp1s0", @@ -924,6 +1115,7 @@ func Test_adoptNicsFromTwin(t *testing.T) { }, }, newSwitch: &metal.Switch{ + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, Nics: metal.Nics{ metal.Nic{ Name: "swp1s1", @@ -934,6 +1126,68 @@ func Test_adoptNicsFromTwin(t *testing.T) { }, wantErr: true, }, + { + name: "switch os from cumulus to sonic", + args: args{ + twin: &metal.Switch{ + OS: &metal.SwitchOS{ + Vendor: metal.SwitchOSVendorCumulus, + }, + Nics: metal.Nics{ + metal.Nic{ + Name: "swp1s0", + MacAddress: "aa:aa:aa:aa:aa:a1", + Vrf: "vrf1", + }, + metal.Nic{ + Name: "swp1s1", + MacAddress: "aa:aa:aa:aa:aa:a2", + Vrf: "", + }, + metal.Nic{ + Name: "swp99", + MacAddress: "aa:aa:aa:aa:aa:a3", + }, + }, + }, + newSwitch: &metal.Switch{ + OS: &metal.SwitchOS{ + Vendor: metal.SwitchOSVendorSonic, + }, + Nics: metal.Nics{ + metal.Nic{ + Name: "Ethernet0", + MacAddress: "bb:bb:bb:bb:bb:b2", + }, + metal.Nic{ + Name: "Ethernet1", + MacAddress: "bb:bb:bb:bb:bb:b3", + }, + metal.Nic{ + Name: "Ethernet392", + MacAddress: "bb:bb:bb:bb:bb:b4", + }, + }, + }, + }, + want: metal.Nics{ + metal.Nic{ + Name: "Ethernet0", + MacAddress: "bb:bb:bb:bb:bb:b2", + Vrf: "vrf1", + }, + metal.Nic{ + Name: "Ethernet1", + MacAddress: "bb:bb:bb:bb:bb:b3", + Vrf: "", + }, + metal.Nic{ + Name: "Ethernet392", + MacAddress: "bb:bb:bb:bb:bb:b4", + }, + }, + wantErr: false, + }, } for i := range tests { @@ -944,8 +1198,8 @@ func Test_adoptNicsFromTwin(t *testing.T) { t.Errorf("adoptNics() error = %v, wantErr %v", err, tt.wantErr) return } - if !reflect.DeepEqual(got.ByIdentifier(), tt.want.ByIdentifier()) { - t.Errorf("adoptNics() = %v, want %v", got, tt.want) + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Errorf("diff %v", diff) } }) } @@ -966,6 +1220,7 @@ func Test_adoptMachineConnections(t *testing.T) { name: "adopt machine connections from twin", args: args{ twin: &metal.Switch{ + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, MachineConnections: metal.ConnectionMap{ "m1": metal.Connections{ metal.Connection{ @@ -986,6 +1241,7 @@ func Test_adoptMachineConnections(t *testing.T) { }, }, newSwitch: &metal.Switch{ + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, Nics: metal.Nics{ metal.Nic{ Name: "swp1s0", @@ -1022,6 +1278,7 @@ func Test_adoptMachineConnections(t *testing.T) { name: "new switch misses nic for existing machine connection at twin", args: args{ twin: &metal.Switch{ + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, MachineConnections: metal.ConnectionMap{ "m1": metal.Connections{ metal.Connection{ @@ -1034,6 +1291,7 @@ func Test_adoptMachineConnections(t *testing.T) { }, }, newSwitch: &metal.Switch{ + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, Nics: metal.Nics{ metal.Nic{ Name: "swp1s1", @@ -1044,6 +1302,64 @@ func Test_adoptMachineConnections(t *testing.T) { }, wantErr: true, }, + { + name: "adopt from twin with different switch os", + args: args{ + twin: &metal.Switch{ + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, + MachineConnections: metal.ConnectionMap{ + "m1": metal.Connections{ + metal.Connection{ + Nic: metal.Nic{ + Name: "swp1s0", + MacAddress: "aa:aa:aa:aa:aa:a1", + }, + }, + }, + "m2": metal.Connections{ + metal.Connection{ + Nic: metal.Nic{ + Name: "swp1s1", + MacAddress: "aa:aa:aa:aa:aa:a2", + }, + }, + }, + }, + }, + newSwitch: &metal.Switch{ + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorSonic}, + Nics: metal.Nics{ + metal.Nic{ + Name: "Ethernet0", + MacAddress: "bb:bb:bb:bb:bb:b1", + }, + metal.Nic{ + Name: "Ethernet1", + MacAddress: "bb:bb:bb:bb:bb:b2", + }, + }, + }, + }, + want: metal.ConnectionMap{ + "m1": metal.Connections{ + metal.Connection{ + Nic: metal.Nic{ + Name: "Ethernet0", + MacAddress: "bb:bb:bb:bb:bb:b1", + }, + }, + }, + "m2": metal.Connections{ + metal.Connection{ + Nic: metal.Nic{ + Name: "Ethernet1", + MacAddress: "bb:bb:bb:bb:bb:b2", + }, + }, + }, + }, + wantErr: false, + }, } for i := range tests { @@ -1469,6 +1785,188 @@ func TestToggleSwitchNicWithoutMachine(t *testing.T) { require.Equal(t, result.Message, fmt.Sprintf("switch %q does not have a connected machine at port %q", testdata.Switch1.ID, testdata.Switch1.Nics[1].Name)) } +func Test_adjustMachineNics(t *testing.T) { + tests := []struct { + name string + nics metal.Nics + connections metal.Connections + nicMap metal.NicMap + want metal.Nics + wantErr bool + }{ + { + name: "nothing to adjust", + nics: []metal.Nic{ + { + Name: "eth0", + MacAddress: "11:11:11:11:11:11", + Neighbors: []metal.Nic{ + { + Name: "swp1", + MacAddress: "aa:aa:aa:aa:aa:aa", + }, + }, + }, + { + Name: "eth1", + MacAddress: "11:11:11:11:11:22", + Neighbors: []metal.Nic{ + { + Name: "swp1", + MacAddress: "aa:aa:aa:aa:aa:bb", + }, + }, + }, + }, + connections: []metal.Connection{ + { + Nic: metal.Nic{ + Name: "swp1", + MacAddress: "cc:cc:cc:cc:cc:cc", + }, + }, + }, + want: []metal.Nic{ + { + Name: "eth0", + MacAddress: "11:11:11:11:11:11", + Neighbors: []metal.Nic{ + { + Name: "swp1", + MacAddress: "aa:aa:aa:aa:aa:aa", + }, + }, + }, + { + Name: "eth1", + MacAddress: "11:11:11:11:11:22", + Neighbors: []metal.Nic{ + { + Name: "swp1", + MacAddress: "aa:aa:aa:aa:aa:bb", + }, + }, + }, + }, + wantErr: false, + }, + { + name: "unrealistic error case", + nics: []metal.Nic{ + { + Name: "eth0", + MacAddress: "11:11:11:11:11:11", + Neighbors: []metal.Nic{ + { + Name: "swp2", + MacAddress: "aa:aa:aa:aa:aa:aa", + }, + }, + }, + { + Name: "eth1", + MacAddress: "11:11:11:11:11:22", + Neighbors: []metal.Nic{ + { + Name: "swp2", + MacAddress: "aa:aa:aa:aa:aa:bb", + }, + }, + }, + }, + connections: []metal.Connection{ + { + Nic: metal.Nic{ + Name: "swp2", + MacAddress: "aa:aa:aa:aa:aa:aa", + }, + }, + }, + nicMap: map[string]*metal.Nic{ + "swp1": { + Name: "Ethernet0", + MacAddress: "dd:dd:dd:dd:dd:dd", + }, + }, + want: nil, + wantErr: true, + }, + { + name: "adjust nics from cumulus to sonic", + nics: []metal.Nic{ + { + Name: "eth0", + MacAddress: "11:11:11:11:11:11", + Neighbors: []metal.Nic{ + { + Name: "swp1", + MacAddress: "aa:aa:aa:aa:aa:aa", + }, + }, + }, + { + Name: "eth1", + MacAddress: "11:11:11:11:11:22", + Neighbors: []metal.Nic{ + { + Name: "swp1", + MacAddress: "aa:aa:aa:aa:aa:bb", + }, + }, + }, + }, + connections: []metal.Connection{ + { + Nic: metal.Nic{ + Name: "swp1", + MacAddress: "aa:aa:aa:aa:aa:aa", + }, + }, + }, + nicMap: map[string]*metal.Nic{ + "swp1": { + Name: "Ethernet0", + MacAddress: "dd:dd:dd:dd:dd:dd", + }, + }, + want: []metal.Nic{ + { + Name: "eth0", + MacAddress: "11:11:11:11:11:11", + Neighbors: []metal.Nic{ + { + Name: "Ethernet0", + MacAddress: "dd:dd:dd:dd:dd:dd", + }, + }, + }, + { + Name: "eth1", + MacAddress: "11:11:11:11:11:22", + Neighbors: []metal.Nic{ + { + Name: "swp1", + MacAddress: "aa:aa:aa:aa:aa:bb", + }, + }, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := adjustMachineNics(tt.nics, tt.connections, tt.nicMap) + if (err != nil) != tt.wantErr { + t.Errorf("adjustMachineNics() error = %v, wantErr %v", err, tt.wantErr) + } + if diff := cmp.Diff(got, tt.want); diff != "" { + t.Errorf("adjustMachineNics() diff = %v", diff) + } + }) + } +} + func Test_SwitchDelete(t *testing.T) { tests := []struct { name string diff --git a/cmd/metal-api/internal/service/v1/switch.go b/cmd/metal-api/internal/service/v1/switch.go index 85c214c0..514ecbdc 100644 --- a/cmd/metal-api/internal/service/v1/switch.go +++ b/cmd/metal-api/internal/service/v1/switch.go @@ -32,9 +32,9 @@ type SwitchBase struct { } type SwitchOS struct { - Vendor string `json:"vendor" description:"the operating system vendor the switch currently has" optional:"true"` - Version string `json:"version" description:"the operating system version the switch currently has" optional:"true"` - MetalCoreVersion string `json:"metal_core_version" description:"the version of metal-core running" optional:"true"` + Vendor metal.SwitchOSVendor `json:"vendor" description:"the operating system vendor the switch currently has" optional:"true" enum:"SONiC|Cumulus"` + Version string `json:"version" description:"the operating system version the switch currently has" optional:"true"` + MetalCoreVersion string `json:"metal_core_version" description:"the version of metal-core running" optional:"true"` } type SwitchNics []SwitchNic @@ -90,6 +90,12 @@ type SwitchPortToggleRequest struct { Status SwitchPortStatus `json:"status" description:"sets the port status" enum:"UP|DOWN"` } +// SwitchMigrateResponse is used to migrate from one switch to another, e.g. for changing os vendor. +type SwitchMigrateRequest struct { + OldSwitchID string `json:"old_switch_id" description:"the id of the switch that should be migrated away from"` + NewSwitchID string `json:"new_switch_id" description:"the id of the new switch to migrate to"` +} + // SwitchNotifyRequest represents the notification sent from the switch // to the metal-api after a sync operation. It contains the duration of // the sync, any error that occurred, and the updated switch port states. diff --git a/cmd/metal-api/internal/testdata/testdata.go b/cmd/metal-api/internal/testdata/testdata.go index 0f8a5b9a..fdabd77e 100644 --- a/cmd/metal-api/internal/testdata/testdata.go +++ b/cmd/metal-api/internal/testdata/testdata.go @@ -533,6 +533,7 @@ var ( Base: metal.Base{ ID: "switch1", }, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, PartitionID: "1", RackID: "1", Nics: []metal.Nic{ @@ -543,7 +544,7 @@ var ( "1": metal.Connections{ metal.Connection{ Nic: metal.Nic{ - Name: "swp1", + Name: "swp2", MacAddress: metal.MacAddress("21:11:11:11:11:11"), }, MachineID: "1", @@ -561,6 +562,7 @@ var ( Base: metal.Base{ ID: "switch2", }, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, PartitionID: "1", RackID: "1", Nics: []metal.Nic{ @@ -572,6 +574,7 @@ var ( Base: metal.Base{ ID: "switch3", }, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, PartitionID: "1", RackID: "3", MachineConnections: metal.ConnectionMap{}, @@ -580,6 +583,7 @@ var ( Base: metal.Base{ ID: "switch1", }, + OS: &metal.SwitchOS{Vendor: metal.SwitchOSVendorCumulus}, PartitionID: "1", RackID: "1", Nics: []metal.Nic{ @@ -602,7 +606,7 @@ var ( // Nics Nic1 = metal.Nic{ MacAddress: metal.MacAddress("11:11:11:11:11:11"), - Name: "eth0", + Name: "swp1", Neighbors: []metal.Nic{ { MacAddress: "21:11:11:11:11:11", @@ -614,7 +618,7 @@ var ( } Nic2 = metal.Nic{ MacAddress: metal.MacAddress("21:11:11:11:11:11"), - Name: "swp1", + Name: "swp2", Neighbors: []metal.Nic{ { MacAddress: "11:11:11:11:11:11", @@ -626,7 +630,7 @@ var ( } Nic3 = metal.Nic{ MacAddress: metal.MacAddress("31:11:11:11:11:11"), - Name: "swp2", + Name: "swp3", Neighbors: []metal.Nic{ { MacAddress: "21:11:11:11:11:11", @@ -638,7 +642,7 @@ var ( } Nic4 = metal.Nic{ MacAddress: metal.MacAddress("41:11:11:11:11:11"), - Name: "swp1", + Name: "swp2", } // IPMIs @@ -748,24 +752,6 @@ var ( "33:11:11:11:11:11", } - // Create the Connections Array - TestConnections = []metal.Connection{ - { - Nic: metal.Nic{ - Name: "swp1", - MacAddress: "11:11:11", - }, - MachineID: "machine-1", - }, - { - Nic: metal.Nic{ - Name: "swp2", - MacAddress: "22:11:11", - }, - MachineID: "machine-2", - }, - } - TestMachinesHardwares = []metal.MachineHardware{ MachineHardware1, MachineHardware2, } diff --git a/spec/metal-api.json b/spec/metal-api.json index d73f9e05..3c42aae5 100644 --- a/spec/metal-api.json +++ b/spec/metal-api.json @@ -5075,6 +5075,22 @@ } } }, + "v1.SwitchMigrateRequest": { + "properties": { + "new_switch_id": { + "description": "the id of the new switch to migrate to", + "type": "string" + }, + "old_switch_id": { + "description": "the id of the switch that should be migrated away from", + "type": "string" + } + }, + "required": [ + "new_switch_id", + "old_switch_id" + ] + }, "v1.SwitchNic": { "properties": { "actual": { @@ -5173,6 +5189,10 @@ }, "vendor": { "description": "the operating system vendor the switch currently has", + "enum": [ + "Cumulus", + "SONiC" + ], "type": "string" }, "version": { @@ -9728,6 +9748,45 @@ ] } }, + "/v1/switch/migrate": { + "post": { + "consumes": [ + "application/json" + ], + "operationId": "migrateSwitch", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.SwitchMigrateRequest" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/v1.SwitchResponse" + } + }, + "default": { + "description": "Error", + "schema": { + "$ref": "#/definitions/httperrors.HTTPErrorResponse" + } + } + }, + "summary": "migrates machine connections from one switch to another", + "tags": [ + "switch" + ] + } + }, "/v1/switch/register": { "post": { "consumes": [