From 55954bdb91d33923ad2465cc4270b506dff1f036 Mon Sep 17 00:00:00 2001 From: yrajukurapati Date: Tue, 18 Nov 2025 13:54:20 -0800 Subject: [PATCH] Implement go-openvswitch API for ofport/interface mapping --- ovs/openflow.go | 165 ++++++++++++++++++++ ovs/openflow_test.go | 358 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 523 insertions(+) diff --git a/ovs/openflow.go b/ovs/openflow.go index f431413..cdccdd9 100644 --- a/ovs/openflow.go +++ b/ovs/openflow.go @@ -21,6 +21,9 @@ import ( "errors" "fmt" "io" + "net" + "strconv" + "strings" ) var ( @@ -69,6 +72,80 @@ type flowDirective struct { flow string } +// PortAttr contains the ofport number and MAC address for an interface. +type PortAttr struct { + // ofPort specifies the OpenFlow port number. + ofPort int + + // macAddress specifies the MAC address of the interface. + macAddress string +} + +// PortMapping contains the interface name, ofport number and MAC address for an interface. +type PortMapping struct { + portAttr PortAttr + ifName string +} + +// UnmarshalText unmarshals a PortMapping from textual form as output by +// 'ovs-ofctl show': +// +// 7(interface1): addr:fe:4f:76:09:88:2b +func (p *PortMapping) UnmarshalText(b []byte) error { + // Make a copy per documentation for encoding.TextUnmarshaler. + s := strings.TrimSpace(string(b)) + + // Expected format: " 7(interface1): addr:fe:4f:76:09:88:2b" + // Find the opening parenthesis + openParen := strings.IndexByte(s, '(') + if openParen == -1 { + return fmt.Errorf("invalid port mapping format") + } + + // Find the closing parenthesis in the full string + closeParen := strings.IndexByte(s, ')') + if closeParen == -1 || closeParen <= openParen { + return fmt.Errorf("invalid port mapping format") + } + + // Extract interface name (between parentheses) + ifName := s[openParen+1 : closeParen] + + // Extract ofport number (before opening parenthesis) + ofportStr := strings.TrimSpace(s[:openParen]) + ofport, err := strconv.Atoi(ofportStr) + if err != nil { + return fmt.Errorf("invalid ofport number: %w", err) + } + + // Validate ofport is in valid OpenFlow port range [0, 65535] + if ofport < 0 || ofport > 65535 { + return fmt.Errorf("ofport %d out of valid range [0, 65535]", ofport) + } + + // Find "addr:" after the closing parenthesis in the full string + addrPrefix := ": addr:" + addrIdx := strings.Index(s, addrPrefix) + if addrIdx == -1 || addrIdx <= closeParen { + return fmt.Errorf("invalid port mapping format") + } + addrIdx += len(addrPrefix) + + // Extract MAC address (after "addr:") + macAddress := strings.TrimSpace(s[addrIdx:]) + + // Validate MAC address format + if _, err := net.ParseMAC(macAddress); err != nil { + return fmt.Errorf("invalid MAC address %q: %w", macAddress, err) + } + + p.portAttr.ofPort = ofport + p.portAttr.macAddress = macAddress + p.ifName = ifName + + return nil +} + // Possible flowDirective directive values. const ( dirAdd = "add" @@ -324,6 +401,57 @@ func (o *OpenFlowService) DumpAggregate(bridge string, flow *MatchFlow) (*FlowSt return stats, nil } +// DumpPortMapping retrieves port mapping (ofport and MAC address) for a +// specific interface from the specified bridge. +func (o *OpenFlowService) DumpPortMapping(bridge string, interfaceName string) (*PortAttr, error) { + mappings, err := o.DumpPortMappings(bridge) + if err != nil { + return nil, err + } + + mapping, ok := mappings[interfaceName] + if !ok { + return nil, fmt.Errorf("interface %q not found", interfaceName) + } + + return mapping, nil +} + +// DumpPortMappings retrieves port mappings (ofport and MAC address) for all +// interfaces from the specified bridge. The output is parsed from 'ovs-ofctl show' command. +func (o *OpenFlowService) DumpPortMappings(bridge string) (map[string]*PortAttr, error) { + args := []string{"show", bridge} + args = append(args, o.c.ofctlFlags...) + + out, err := o.exec(args...) + if err != nil { + return nil, err + } + + mappings := make(map[string]*PortAttr) + err = parseEachPort(out, showPrefix, func(line []byte) error { + // Parse the PortMapping - UnmarshalText validates format and extracts all fields + pm := new(PortMapping) + if err := pm.UnmarshalText(line); err != nil { + // Skip malformed lines + return nil + } + + // Use interface name from PortMapping as map key, copy PortAttr values + mappings[pm.ifName] = &PortAttr{ + ofPort: pm.portAttr.ofPort, + macAddress: pm.portAttr.macAddress, + } + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse port mappings: %w", err) + } + + return mappings, nil +} + var ( // dumpPortsPrefix is a sentinel value returned at the beginning of // the output from 'ovs-ofctl dump-ports'. @@ -343,6 +471,10 @@ var ( // dumpAggregatePrefix is a sentinel value returned at the beginning of // the output from "ovs-ofctl dump-aggregate" //dumpAggregatePrefix = []byte("NXST_AGGREGATE reply") + + // showPrefix is a sentinel value returned at the beginning of + // the output from 'ovs-ofctl show'. + showPrefix = []byte("OFPT_FEATURES_REPLY") ) // dumpPorts calls 'ovs-ofctl dump-ports' with the specified arguments and @@ -497,6 +629,39 @@ func parseEach(in []byte, prefix []byte, fn func(b []byte) error) error { return scanner.Err() } +// parseEachPort parses ovs-ofctl show output from the input buffer, ensuring it has the +// specified prefix, and invoking the input function on each line scanned. +func parseEachPort(in []byte, prefix []byte, fn func(b []byte) error) error { + // Handle empty input - return nil to allow empty mappings (matches test expectations) + if len(in) == 0 { + return nil + } + + // First line must not be empty + scanner := bufio.NewScanner(bytes.NewReader(in)) + scanner.Split(bufio.ScanLines) + if !scanner.Scan() { + return io.ErrUnexpectedEOF + } + + // First line must contain the prefix returned by OVS + if !bytes.Contains(scanner.Bytes(), prefix) { + return io.ErrUnexpectedEOF + } + + // Scan every line to retrieve information needed to unmarshal + // a single PortMapping struct. + for scanner.Scan() { + b := make([]byte, len(scanner.Bytes())) + copy(b, scanner.Bytes()) + if err := fn(b); err != nil { + return err + } + } + + return scanner.Err() +} + // exec executes an ExecFunc using 'ovs-ofctl'. func (o *OpenFlowService) exec(args ...string) ([]byte, error) { return o.c.exec("ovs-ofctl", args...) diff --git a/ovs/openflow_test.go b/ovs/openflow_test.go index aea427c..0ef9664 100644 --- a/ovs/openflow_test.go +++ b/ovs/openflow_test.go @@ -1415,3 +1415,361 @@ func mustVerifyFlowBundle(t *testing.T, stdin io.Reader, flows []*Flow, matchFlo } } } + +func TestClientOpenFlowDumpPortMappingsOK(t *testing.T) { + want := map[string]*PortAttr{ + "interface1": { + ofPort: 7, + macAddress: "fe:4f:76:09:88:2b", + }, + "interface2": { + ofPort: 8, + macAddress: "fe:be:7b:0d:53:d8", + }, + "interface3": { + ofPort: 9, + macAddress: "fe:b6:4c:d5:40:79", + }, + "interface4": { + ofPort: 20, + macAddress: "fe:cf:a6:90:30:29", + }, + "LOCAL": { + ofPort: 65534, + macAddress: "fe:74:0f:80:cf:9a", + }, + "eth0": { + ofPort: 1, + macAddress: "aa:bb:cc:dd:ee:ff", + }, + } + + bridge := "br0" + + c := testClient([]OptionFunc{Timeout(1)}, func(cmd string, args ...string) ([]byte, error) { + // Verify correct command and arguments passed, including option flags + if want, got := "ovs-ofctl", cmd; want != got { + t.Fatalf("incorrect command:\n- want: %v\n- got: %v", + want, got) + } + + wantArgs := []string{"--timeout=1", "show", bridge} + if want, got := wantArgs, args; !reflect.DeepEqual(want, got) { + t.Fatalf("incorrect arguments\n- want: %v\n- got: %v", + want, got) + } + + return []byte(` +OFPT_FEATURES_REPLY (xid=0x2): dpid:0000000000000001 +n_tables:254, n_buffers:256 +capabilities: FLOW_STATS TABLE_STATS PORT_STATS QUEUE_STATS ARP_MATCH_IP +actions: output enqueue set_vlan_vid set_vlan_pcp strip_vlan mod_dl_src mod_dl_dst mod_nw_src mod_nw_dst mod_nw_tos mod_tp_src mod_tp_dst + 7(interface1): addr:fe:4f:76:09:88:2b + config: 0 + state: 0 + current: 10GB-FD COPPER + speed: 10000 Mbps now, 0 Mbps max + 8(interface2): addr:fe:be:7b:0d:53:d8 + config: 0 + state: 0 + current: 10GB-FD COPPER + speed: 10000 Mbps now, 0 Mbps max + 9(interface3): addr:fe:b6:4c:d5:40:79 + config: 0 + state: 0 + current: 10GB-FD COPPER + speed: 10000 Mbps now, 0 Mbps max + 20(interface4): addr:fe:cf:a6:90:30:29 + config: 0 + state: 0 + current: 10GB-FD COPPER + speed: 10000 Mbps now, 0 Mbps max + 65534(LOCAL): addr:fe:74:0f:80:cf:9a + config: 0 + state: 0 + current: 10GB-FD COPPER + speed: 10000 Mbps now, 0 Mbps max + 1(eth0): addr:aa:bb:cc:dd:ee:ff + config: 0 + state: 0 +`), nil + }) + + got, err := c.OpenFlow.DumpPortMappings(bridge) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(want) != len(got) { + t.Fatalf("unexpected number of mappings:\n- want: %d\n- got: %d", + len(want), len(got)) + } + + for name, wantMapping := range want { + gotMapping, ok := got[name] + if !ok { + t.Fatalf("missing mapping for interface %q", name) + } + + if wantMapping.ofPort != gotMapping.ofPort { + t.Fatalf("unexpected ofPort for %q:\n- want: %d\n- got: %d", + name, wantMapping.ofPort, gotMapping.ofPort) + } + + if wantMapping.macAddress != gotMapping.macAddress { + t.Fatalf("unexpected macAddress for %q:\n- want: %q\n- got: %q", + name, wantMapping.macAddress, gotMapping.macAddress) + } + } +} + +func TestClientOpenFlowDumpPortMappingsEmptyOutput(t *testing.T) { + bridge := "br0" + + c := testClient(nil, func(cmd string, args ...string) ([]byte, error) { + return []byte(""), nil + }) + + got, err := c.OpenFlow.DumpPortMappings(bridge) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got) != 0 { + t.Fatalf("unexpected mappings for empty output:\n- want: 0\n- got: %d", len(got)) + } +} + +func TestClientOpenFlowDumpPortMappingsAllInterfaces(t *testing.T) { + want := map[string]*PortAttr{ + "eth0": { + ofPort: 1, + macAddress: "aa:bb:cc:dd:ee:ff", + }, + "eth1": { + ofPort: 2, + macAddress: "11:22:33:44:55:66", + }, + } + + bridge := "br0" + + c := testClient(nil, func(cmd string, args ...string) ([]byte, error) { + return []byte(` +OFPT_FEATURES_REPLY (xid=0x2): dpid:0000000000000001 + 1(eth0): addr:aa:bb:cc:dd:ee:ff + 2(eth1): addr:11:22:33:44:55:66 +`), nil + }) + + got, err := c.OpenFlow.DumpPortMappings(bridge) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(want) != len(got) { + t.Fatalf("unexpected number of mappings:\n- want: %d\n- got: %d", len(want), len(got)) + } + + for name, wantMapping := range want { + gotMapping, ok := got[name] + if !ok { + t.Fatalf("missing mapping for interface %q", name) + } + + if wantMapping.ofPort != gotMapping.ofPort { + t.Fatalf("unexpected ofPort for %q:\n- want: %d\n- got: %d", + name, wantMapping.ofPort, gotMapping.ofPort) + } + + if wantMapping.macAddress != gotMapping.macAddress { + t.Fatalf("unexpected macAddress for %q:\n- want: %q\n- got: %q", + name, wantMapping.macAddress, gotMapping.macAddress) + } + } +} + +func TestClientOpenFlowDumpPortMappingsForInterface(t *testing.T) { + tests := []struct { + name string + interfaceName string + want *PortAttr + output string + }{ + { + name: "LOCAL interface", + interfaceName: "LOCAL", + want: &PortAttr{ + ofPort: 65534, + macAddress: "fe:74:0f:80:cf:9a", + }, + output: ` +OFPT_FEATURES_REPLY (xid=0x2): dpid:0000000000000001 + 65534(LOCAL): addr:fe:74:0f:80:cf:9a +`, + }, + { + name: "interface1", + interfaceName: "interface1", + want: &PortAttr{ + ofPort: 7, + macAddress: "fe:4f:76:09:88:2b", + }, + output: ` +OFPT_FEATURES_REPLY (xid=0x2): dpid:0000000000000001 + 7(interface1): addr:fe:4f:76:09:88:2b +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bridge := "br0" + + c := testClient(nil, func(cmd string, args ...string) ([]byte, error) { + return []byte(tt.output), nil + }) + + got, err := c.OpenFlow.DumpPortMappings(bridge) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + gotMapping, ok := got[tt.interfaceName] + if !ok { + t.Fatalf("missing mapping for interface %q", tt.interfaceName) + } + + if tt.want.ofPort != gotMapping.ofPort { + t.Fatalf("unexpected ofPort for %q:\n- want: %d\n- got: %d", + tt.interfaceName, tt.want.ofPort, gotMapping.ofPort) + } + + if tt.want.macAddress != gotMapping.macAddress { + t.Fatalf("unexpected macAddress for %q:\n- want: %q\n- got: %q", + tt.interfaceName, tt.want.macAddress, gotMapping.macAddress) + } + }) + } +} + +func TestClientOpenFlowDumpPortMappingsCommandError(t *testing.T) { + bridge := "br0" + wantErr := errors.New("command failed") + + c := testClient(nil, func(cmd string, args ...string) ([]byte, error) { + return nil, wantErr + }) + + _, err := c.OpenFlow.DumpPortMappings(bridge) + if err == nil { + t.Fatalf("expected error, got nil") + } + + if !strings.Contains(err.Error(), wantErr.Error()) { + t.Fatalf("unexpected error:\n- want: contains %q\n- got: %v", wantErr.Error(), err) + } +} + +func TestClientOpenFlowDumpPortMapping(t *testing.T) { + tests := []struct { + name string + interfaceName string + want *PortAttr + output string + wantErr bool + errMsg string + }{ + { + name: "successful retrieval", + interfaceName: "interface1", + want: &PortAttr{ + ofPort: 7, + macAddress: "fe:4f:76:09:88:2b", + }, + output: ` +OFPT_FEATURES_REPLY (xid=0x2): dpid:0000000000000001 + 7(interface1): addr:fe:4f:76:09:88:2b + 8(interface2): addr:fe:be:7b:0d:53:d8 +`, + wantErr: false, + }, + { + name: "interface not found", + interfaceName: "nonexistent", + output: ` +OFPT_FEATURES_REPLY (xid=0x2): dpid:0000000000000001 + 7(interface1): addr:fe:4f:76:09:88:2b +`, + wantErr: true, + errMsg: "interface \"nonexistent\" not found", + }, + { + name: "LOCAL interface", + interfaceName: "LOCAL", + want: &PortAttr{ + ofPort: 65534, + macAddress: "fe:74:0f:80:cf:9a", + }, + output: ` +OFPT_FEATURES_REPLY (xid=0x2): dpid:0000000000000001 + 65534(LOCAL): addr:fe:74:0f:80:cf:9a +`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bridge := "br0" + + c := testClient(nil, func(cmd string, args ...string) ([]byte, error) { + return []byte(tt.output), nil + }) + + got, err := c.OpenFlow.DumpPortMapping(bridge, tt.interfaceName) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Fatalf("unexpected error message:\n- want: contains %q\n- got: %v", tt.errMsg, err) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.want.ofPort != got.ofPort { + t.Fatalf("unexpected ofPort:\n- want: %d\n- got: %d", + tt.want.ofPort, got.ofPort) + } + + if tt.want.macAddress != got.macAddress { + t.Fatalf("unexpected macAddress:\n- want: %q\n- got: %q", + tt.want.macAddress, got.macAddress) + } + }) + } +} + +func TestClientOpenFlowDumpPortMappingCommandError(t *testing.T) { + bridge := "br0" + interfaceName := "interface1" + wantErr := errors.New("command failed") + + c := testClient(nil, func(cmd string, args ...string) ([]byte, error) { + return nil, wantErr + }) + + _, err := c.OpenFlow.DumpPortMapping(bridge, interfaceName) + if err == nil { + t.Fatalf("expected error, got nil") + } + + if !strings.Contains(err.Error(), wantErr.Error()) { + t.Fatalf("unexpected error:\n- want: contains %q\n- got: %v", wantErr.Error(), err) + } +}