Repository: annetutil/annet

Issue:
Issue with mesh obj.addr check when separate_ports option is used
I have two separate ports connected to switch and if I will try to gen same IP address for both sessions
```python
@registry.direct(
    "{loc:dc1|dc2}-leaf{num}",
    "my_favorite_server",
    port_processor=separate_ports
)
def leaf_to_server(leaf: DirectPeer, server: DirectPeer, session: MeshSession):
    leaf.asnum = leaf.device.custom_fields.get("asn")
    leaf.group_name = "Servers"
    leaf.families = {"ipv6_unicast"}
    leaf.addr = "fd00:199:a:1::1"
    
    server.asnum = server.device.custom_fields.get("asn")
    server.group_name = "Leafs"
    server.families = {"ipv6_unicast"}
    server.addr = "fd00:199:a:1::2"
```
And get traceback:
```
Traceback (most recent call last):
  File "/Users/anteron/py312/lib/python3.12/site-packages/annet/generators/__init__.py", line 186, in _run_partial_generator
    output = gen(device, run_args.annotate)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/anteron/py312/lib/python3.12/site-packages/annet/generators/partial.py", line 79, in __call__
    for text in self._running_gen:
                ^^^^^^^^^^^^^^^^^
  File "/Users/anteron/annet-mws/generators/l3.py", line 114, in run_b4com
    mesh_data: BgpConfig = bgp_mesh(device)
                           ^^^^^^^^^^^^^^^^
  File "/Users/anteron/annet-mws/generators/helpers/router.py", line 45, in bgp_mesh
    mesh_data: BgpConfig = executor.execute_for(device)
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/anteron/py312/lib/python3.12/site-packages/annet/mesh/executor.py", line 368, in execute_for
    for direct_pair in self._execute_direct(device):
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/anteron/py312/lib/python3.12/site-packages/annet/mesh/executor.py", line 184, in _execute_direct
    raise ValueError(
ValueError: Handler `Users_anteron_annet-mws_generators___init___py.mesh_views.leaf.leaf_to_server` provides data conflicting with previously loaded for device pair ('dc1-leaf01', 'my_favorite_server') with addr=fd00:199:a:1::2, vrf:
Override with different value is forbidden for field ports:
Old: ['ce1']
New: ['ce2']
```
@Tishka17 JFYI

Hints from issue comments:
@Tishka17: Looks like we need to handle which port is used for BGP session as link-local addresses can be reused

Solution patch:
diff --git a/annet/mesh/executor.py b/annet/mesh/executor.py
index 920701b..d751471 100644
--- a/annet/mesh/executor.py
+++ b/annet/mesh/executor.py
@@ -22,11 +22,34 @@ from .registry import (
 logger = getLogger(__name__)
 
 
+@dataclass(frozen=True)
+class TargetInterface:
+    subif: int | None = None
+    svi: int | None = None
+    lag: int | None = None
+    port: str | None = None
+
+
 @dataclass(frozen=True)
 class PeerKey:
     fqdn: str
     addr: str
     vrf: str
+    interface: TargetInterface
+
+
+def target_interface(
+        peer: DirectPeerDTO | IndirectPeerDTO | VirtualLocalDTO,
+        ports: list[str],
+) -> TargetInterface:
+    subif = getattr(peer, "subif", None)
+    svi = getattr(peer, "svi", None)
+    lag = getattr(peer, "lag", None)
+    port = None
+    if not (svi or lag):
+        if len(ports) == 1:
+            port = ports[0]
+    return TargetInterface(subif, svi, lag, port)
 
 
 class Pair(BaseMeshModel):
@@ -171,7 +194,8 @@ class MeshExecutor:
                 peer_key = PeerKey(
                     fqdn=pair.device.fqdn,
                     addr=addr,
-                    vrf=getattr(pair.connected, "vrf", "")
+                    vrf=getattr(pair.connected, "vrf", ""),
+                    interface=target_interface(pair.local, [p[0] for p in ports]),
                 )
                 try:
                     if peer_key in neighbor_peers:
@@ -278,7 +302,8 @@ class MeshExecutor:
             peer_key = PeerKey(
                 fqdn=connected_device.fqdn,
                 addr=addr,
-                vrf=getattr(connected_dto, "vrf", "")
+                vrf=getattr(connected_dto, "vrf", ""),
+                interface=target_interface(pair.local, []),
             )
             try:
                 if peer_key in connected_peers:


Test patch:
diff --git a/tests/annet/test_mesh/test_executor.py b/tests/annet/test_mesh/test_executor.py
index ab9ee34..c97d3d0 100644
--- a/tests/annet/test_mesh/test_executor.py
+++ b/tests/annet/test_mesh/test_executor.py
@@ -18,6 +18,7 @@ from annet.mesh import (
     VirtualLocal,
     VirtualPeer,
     separate_ports,
+    united_ports,
 )
 from .fakes import FakeStorage, FakeDevice, FakeInterface
 
@@ -374,3 +375,77 @@ def test_2vrfs(storage, direct, device1, device_neighbor, device2):
     peer = res_dev2.peers[0]
     assert peer.vrf_name == ""
     assert connected_device.find_interface("Vlan2").addrs == [('192.168.1.1', None)]
+
+
+@pytest.fixture
+def two_connections_storage():
+    """Storage with devices which have 2 connections between"""
+    device1 = FakeDevice(DEV1, [
+        FakeInterface("if1", DEV2, "if11"),
+        FakeInterface("if2", DEV2, "if12"),
+    ])
+    device2 = FakeDevice(DEV2, [
+        FakeInterface("if11", DEV1, "if1"),
+        FakeInterface("if12", DEV1, "if2"),
+    ])
+
+    storage = FakeStorage()
+    storage.add_device(device1)
+    storage.add_device(device2)
+    return storage
+
+
+def two_connections_registry(use_lag: bool):
+    registry = MeshRulesRegistry()
+    if use_lag:
+        port_processor = united_ports
+    else:
+        port_processor = separate_ports
+
+    @registry.direct(DEV1, DEV2, port_processor=port_processor)
+    def on_direct(local: DirectPeer, neighbor: DirectPeer, session: MeshSession):
+        local.addr = "fd00:199:a:1::1"
+        neighbor.addr = "fd00:199:a:1::2"
+        session.asnum = 12345
+        local.af_loops = 42
+        if use_lag:
+            local.lag = 1
+            neighbor.lag = 1
+
+    @registry.direct(DEV1, DEV2, port_processor=port_processor)
+    def on_direct_additional(local: DirectPeer, neighbor: DirectPeer, session: MeshSession):
+        local.addr = "fd00:199:a:1::1"
+        neighbor.addr = "fd00:199:a:1::2"
+        session.asnum = 12345
+        local.export_policy = EXPORT_POLICY1
+        if use_lag:
+            local.lag = 1
+            neighbor.lag = 1
+    return registry
+
+
+def test_two_connections_data_merged(two_connections_storage):
+    registry = two_connections_registry(use_lag=False)
+    executor = MeshExecutor(registry, two_connections_storage)
+    res_dev1 = executor.execute_for(two_connections_storage.devices[0])
+    assert len(res_dev1.peers) == 2
+    assert res_dev1.peers[0].interface == "if1"
+    assert res_dev1.peers[1].interface == "if2"
+    for peer in res_dev1.peers:
+        assert peer.addr == "fd00:199:a:1::2"
+        assert peer.addr == "fd00:199:a:1::2"
+        assert peer.export_policy == EXPORT_POLICY1
+        assert peer.options.af_loops == 42
+
+
+def test_two_connections_lag(two_connections_storage):
+    registry = two_connections_registry(use_lag=True)
+    executor = MeshExecutor(registry, two_connections_storage)
+    res_dev1 = executor.execute_for(two_connections_storage.devices[0])
+    assert len(res_dev1.peers) == 1
+    assert res_dev1.peers[0].interface == "Trunk1"
+    for peer in res_dev1.peers:
+        assert peer.addr == "fd00:199:a:1::2"
+        assert peer.addr == "fd00:199:a:1::2"
+        assert peer.export_policy == EXPORT_POLICY1
+        assert peer.options.af_loops == 42
\ No newline at end of file