Repository: annetutil/annet (language: python)

Issue:
main docs structure



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml
new file mode 100644
index 0000000..1595136
--- /dev/null
+++ b/.github/workflows/docs.yaml
@@ -0,0 +1,36 @@
+name: Docs
+on:
+  workflow_dispatch:
+  push:
+    tags:
+      - "v*"
+    branches:
+      - "main"
+
+permissions:
+  contents: read
+  pages: write
+  id-token: write
+
+jobs:
+  github_pages:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+      - name: Install python
+        uses: actions/setup-python@v5
+        with:
+          python-version: 3.x
+      - name: Build docs
+        run: |
+          pip install -r requirements-doc.txt
+          sphinx-multiversion docs docs-build
+          cp docs/_templates/gh-pages-redirect.html docs-build/index.html
+      - uses: actions/configure-pages@v5
+      - uses: actions/upload-pages-artifact@v3
+        with:
+          path: 'docs-build'
+      - uses: actions/deploy-pages@v4
\ No newline at end of file
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..f528557
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,10 @@
+version: 2
+build:
+  os: ubuntu-22.04
+  tools:
+    python: "3.10"
+python:
+  install:
+    - method: pip
+      path: .
+    - requirements: requirements-doc.txt
diff --git a/README.md b/README.md
index 9b29328..aa141f9 100644
--- a/README.md
+++ b/README.md
@@ -174,3 +174,20 @@ devices:
 
 Annet uses [Entry Points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html) mechanism for customization.
 For example, you can implement the Storage interface on top of your favorite inventory system.
+
+
+## Building doc
+
+1. Install dependencies:
+
+```shell
+pip install -r requirements-doc.txt
+```
+
+2. Build
+
+```shell
+sphinx-build -M html docs docs-build
+```
+
+3. Open rendered html in browser [docs-build/html/index.html](docs-build/html/index.html)
\ No newline at end of file
diff --git a/annet/adapters/netbox/common/models.py b/annet/adapters/netbox/common/models.py
index 988ec61..6c982a6 100644
--- a/annet/adapters/netbox/common/models.py
+++ b/annet/adapters/netbox/common/models.py
@@ -1,9 +1,10 @@
 from dataclasses import dataclass, field
-from datetime import datetime
-from typing import List, Optional, Any, Dict
+from datetime import datetime, timezone
+from ipaddress import ip_interface, IPv6Interface
+from typing import List, Optional, Any, Dict, Sequence, Callable
 
 from annet.annlib.netdev.views.dump import DumpableView
-from annet.annlib.netdev.views.hardware import HardwareView
+from annet.annlib.netdev.views.hardware import HardwareView, lag_name, svi_name
 from annet.storage import Storage
 
 
@@ -124,6 +125,33 @@ class Interface(Entity):
     ip_addresses: List[IpAddress] = field(default_factory=list)
     vrf: Optional[Entity] = None
     mtu: int | None = None
+    lag: Entity | None = None
+    lag_min_links: int | None = None
+
+    def add_addr(self, address_mask: str, vrf: str | None) -> None:
+        addr = ip_interface(address_mask)
+        if vrf is None:
+            vrf_obj = None
+        else:
+            vrf_obj = Entity(id=0, name=vrf)
+
+        if isinstance(addr, IPv6Interface):
+            family = IpFamily(value=6, label="IPv6")
+        else:
+            family = IpFamily(value=4, label="IPv4")
+        self.ip_addresses.append(IpAddress(
+            id=0,
+            display=address_mask,
+            address=address_mask,
+            vrf=vrf_obj,
+            prefix=None,
+            family=family,
+            created=datetime.now(timezone.utc),
+            last_updated=datetime.now(timezone.utc),
+            tags=[],
+            status=Label(value="active", label="Active"),
+            assigned_object_id=self.id,
+        ))
 
 
 @dataclass
@@ -160,6 +188,17 @@ class NetboxDevice(Entity):
     neighbours: Optional[List["NetboxDevice"]]
 
     # compat
+    @property
+    def neighbours_fqdns(self) -> list[str]:
+        if not self.neighbours:
+            return []
+        return [dev.fqdn for dev in self.neighbours]
+
+    @property
+    def neighbours_ids(self):
+        if not self.neighbours:
+            return []
+        return [dev.id for dev in self.neighbours]
 
     def __hash__(self):
         return hash((self.id, type(self)))
@@ -169,3 +208,69 @@ def __eq__(self, other):
 
     def is_pc(self) -> bool:
         return self.device_type.manufacturer.name == "Mellanox"
+
+    def _make_interface(self, name: str, type: InterfaceType) -> Interface:
+        return Interface(
+            name=name,
+            device=self,
+            enabled=True,
+            description="",
+            type=type,
+            id=0,
+            vrf=None,
+            display=name,
+            untagged_vlan=None,
+            tagged_vlans=[],
+            ip_addresses=[],
+            connected_endpoints=[],
+            mode=None,
+        )
+
+    def _lag_name(self, lag: int) -> str:
+        return lag_name(self.hw, lag)
+
+    def make_lag(self, lag: int, ports: Sequence[str], lag_min_links: int | None) -> Interface:
+        new_name = self._lag_name(lag)
+        for target_interface in self.interfaces:
+            if target_interface.name == new_name:
+                return target_interface
+        lag_interface = self._make_interface(
+            name=new_name,
+            type=InterfaceType(value="lag", label="Link Aggregation Group (LAG)"),
+        )
+        lag_interface.lag_min_links = lag_min_links
+        for interface in self.interfaces:
+            if interface.name in ports:
+                interface.lag = lag_interface
+        self.interfaces.append(lag_interface)
+        return lag_interface
+
+    def _svi_name(self, svi: int) -> str:
+        return svi_name(self.hw, svi)
+
+    def add_svi(self, svi: int) -> Interface:
+        name = self._svi_name(svi)
+        for interface in self.interfaces:
+            if interface.name == name:
+                return interface
+        interface = self._make_interface(
+            name=name,
+            type=InterfaceType("virtual", "Virtual")
+        )
+        self.interfaces.append(interface)
+        return interface
+
+    def _subif_name(self, interface: str, subif: int) -> str:
+        return f"{interface}.{subif}"
+
+    def add_subif(self, interface: str, subif: int) -> Interface:
+        name = self._subif_name(interface, subif)
+        for target_port in self.interfaces:
+            if target_port.name == name:
+                return target_port
+        target_port = self._make_interface(
+            name=name,
+            type=InterfaceType("virtual", "Virtual")
+        )
+        self.interfaces.append(target_port)
+        return target_port
diff --git a/annet/adapters/netbox/v37/storage.py b/annet/adapters/netbox/v37/storage.py
index e663f27..ddd4108 100644
--- a/annet/adapters/netbox/v37/storage.py
+++ b/annet/adapters/netbox/v37/storage.py
@@ -15,8 +15,7 @@
 from annet.adapters.netbox.common.query import NetboxQuery
 from annet.adapters.netbox.common.storage_opts import NetboxStorageOpts
 from annet.annlib.netdev.views.hardware import HardwareView
-from annet.storage import Storage
-
+from annet.storage import Storage, Device, Interface
 
 logger = getLogger(__name__)
 
@@ -68,7 +67,9 @@ def extend_device(
     return res
 
 
-@impl_converter
+@impl_converter(
+    recipe=[link_constant(P[models.Interface].lag_min_links, value=None)],
+)
 def extend_interface(
         interface: api_models.Interface,
         ip_addresses: List[models.IpAddress],
@@ -89,6 +90,7 @@ def __init__(self, opts: Optional[NetboxStorageOpts] = None):
             url=opts.url,
             token=opts.token,
         )
+        self._all_fqdns: Optional[list[str]] = None
 
     def __enter__(self):
         return self
@@ -106,6 +108,14 @@ def resolve_fdnds_by_query(self, query: NetboxQuery):
             d.name for d in self._load_devices(query)
         ]
 
+    def resolve_all_fdnds(self) -> list[str]:
+        if self._all_fqdns is None:
+            self._all_fqdns = [
+                d.name
+                for d in self.netbox.dcim_all_devices().results
+            ]
+        return self._all_fqdns
+
     def make_devices(
             self,
             query: Union[NetboxQuery, list],
@@ -218,6 +228,24 @@ def get_device(
     def flush_perf(self):
         pass
 
+    def search_connections(self, device: Device, neighbor: Device) -> list[tuple[Interface, Interface]]:
+        if device.storage is not self:
+            raise ValueError("device does not belong to this storage")
+        if neighbor.storage is not self:
+            raise ValueError("neighbor does not belong to this storage")
+        # both devices are NetboxDevice if they are loaded from this storage
+        res = []
+        for local_port in device.interfaces:
+            if not local_port.connected_endpoints:
+                continue
+            for endpoint in local_port.connected_endpoints:
+                if endpoint.device.id == neighbor.id:
+                    for remote_port in neighbor.interfaces:
+                        if remote_port.name == endpoint.name:
+                            res.append((local_port, remote_port))
+                            break
+        return res
+
 
 def _match_query(query: NetboxQuery, device_data: api_models.Device) -> bool:
     for subquery in query.globs:
diff --git a/annet/annlib/netdev/views/hardware.py b/annet/annlib/netdev/views/hardware.py
index dd8364e..a45c8d3 100644
--- a/annet/annlib/netdev/views/hardware.py
+++ b/annet/annlib/netdev/views/hardware.py
@@ -123,3 +123,34 @@ def vendor_to_hw(vendor):
         None,
     )
     return hw
+
+
+def lag_name(hw: HardwareView, nlagg: int) -> str:
+    if hw.Huawei:
+        return f"Eth-Trunk{nlagg}"
+    if hw.Cisco:
+        return f"port-channel{nlagg}"
+    if hw.Nexus:
+        return f"port-channel{nlagg}"
+    if hw.Arista:
+        return f"Port-Channel{nlagg}"
+    if hw.Juniper:
+        return f"ae{nlagg}"
+    if hw.Nokia:
+        return f"lag-{nlagg}"
+    if hw.PC.Whitebox:
+        return f"bond{nlagg}"
+    if hw.PC:
+        return f"lagg{nlagg}"
+    if hw.Nokia:
+        return f"lagg-{nlagg}"
+    raise NotImplementedError(hw)
+
+
+def svi_name(hw: HardwareView, num: int) -> str:
+    if hw.Juniper:
+        return f"irb.{num}"
+    elif hw.Huawei:
+        return f"Vlanif{num}"
+    else:
+        return f"vlan{num}"
diff --git a/annet/bgp_models.py b/annet/bgp_models.py
new file mode 100644
index 0000000..ef4df78
--- /dev/null
+++ b/annet/bgp_models.py
@@ -0,0 +1,266 @@
+from dataclasses import dataclass, field
+from typing import Literal, Union, Optional
+
+
+class ASN(int):
+    """
+    Stores ASN number and formats it as в AS1.AS2
+    None is treated like 0. Supports integer operations
+    Supported formats: https://tools.ietf.org/html/rfc5396#section-1
+    """
+    PLAIN_MAX = 0x10000
+
+    def __new__(cls, asn: Union[int, str, None, "ASN"]):
+        if isinstance(asn, ASN):
+            return asn
+        elif asn is None:
+            asn = 0
+        elif not isinstance(asn, int):
+            if isinstance(asn, str) and "." in asn:
+                high, low = [int(token) for token in asn.split(".")]
+                if not (0 <= high < ASN.PLAIN_MAX and 0 <= low < ASN.PLAIN_MAX):
+                    raise ValueError("Invalid ASN asn %r" % asn)
+                asn = (high << 16) + low
+            asn = int(asn)
+        if not 0 <= asn <= 0xffffffff:
+            raise ValueError("Invalid ASN asn %r" % asn)
+        return int.__new__(cls, asn)
+
+    def __add__(self, other: int):
+        return ASN(super().__add__(other))
+
+    def __sub__(self, other: int):
+        return ASN(super().__sub__(other))
+
+    def __mul__(self, other: int):
+        return ASN(super().__mul__(other))
+
+    def is_plain(self) -> bool:
+        return self < ASN.PLAIN_MAX
+
+    def asdot(self) -> str:
+        if not self.is_plain():
+            return "%d.%d" % (self // ASN.PLAIN_MAX, self % ASN.PLAIN_MAX)
+        return "%d" % self
+
+    def asplain(self) -> str:
+        return "%d" % self
+
+    def asdot_high(self) -> int:
+        return self // ASN.PLAIN_MAX
+
+    def asdot_low(self) -> int:
+        return self % ASN.PLAIN_MAX
+
+    __str__ = asdot
+
+    def __repr__(self) -> str:
+        srepr = str(self)
+        if "." in srepr:
+            srepr = repr(srepr)
+        return f"{self.__class__.__name__}({srepr})"
+
+
+@dataclass(frozen=True)
+class BFDTimers:
+    minimum_interval: int = 500
+    multiplier: int = 4
+
+
+Family = Literal["ipv4_unicast", "ipv6_unicast", "ipv4_labeled", "ipv6_labeled"]
+
+
+@dataclass(frozen=True)
+class PeerOptions:
+    """The same options as for group but any field is optional"""
+    local_as: Optional[ASN] = None
+    unnumbered: Optional[bool] = None
+    rr_client: Optional[bool] = None
+    next_hop_self: Optional[bool] = None
+    extended_next_hop: Optional[bool] = None
+    send_community: Optional[bool] = None
+    send_lcommunity: Optional[bool] = None
+    send_extcommunity: Optional[bool] = None
+    send_labeled: Optional[bool] = None
+    import_limit: Optional[bool] = None
+    teardown_timeout: Optional[bool] = None
+    redistribute: Optional[bool] = None
+    passive: Optional[bool] = None
+    mtu_discovery: Optional[bool] = None
+    advertise_inactive: Optional[bool] = None
+    advertise_bgp_static: Optional[bool] = None
+    allowas_in: Optional[bool] = None
+    auth_key: Optional[bool] = None
+    add_path: Optional[bool] = None
+    multipath: Optional[bool] = None
+    multihop: Optional[bool] = None
+    multihop_no_nexthop_change: Optional[bool] = None
+    af_no_install: Optional[bool] = None
+    bfd: Optional[bool] = None
+    rib: Optional[bool] = None
+    bfd_timers: Optional[BFDTimers] = None
+    resolve_vpn: Optional[bool] = None
+    af_rib_group: Optional[str] = None
+    af_loops: Optional[int] = None
+    hold_time: Optional[int] = None
+    listen_network: Optional[bool] = None
+    remove_private: Optional[bool] = None
+    as_override: Optional[bool] = None
+    aigp: Optional[bool] = None
+    bmp_monitor: Optional[bool] = None
+    no_prepend: Optional[bool] = None
+    no_explicit_null: Optional[bool] = None
+    uniq_iface: Optional[bool] = None
+    advertise_peer_as: Optional[bool] = None
+    connect_retry: Optional[bool] = None
+    advertise_external: Optional[bool] = None
+    advertise_irb: Optional[bool] = None
+    listen_only: Optional[bool] = None
+    soft_reconfiguration_inbound: Optional[bool] = None
+    not_active: Optional[bool] = None
+    mtu: Optional[int] = None
+
+
+@dataclass
+class Peer:
+    addr: str
+    interface: Optional[str]
+    remote_as: ASN
+    families: set[Family] = field(default_factory=set)
+    description: str = ""
+    vrf_name: str = ""
+    group_name: str = ""
+    import_policy: str = ""
+    export_policy: str = ""
+    update_source: Optional[str] = None
+    options: Optional[PeerOptions] = None
+    hostname: str = ""
+
+
+@dataclass
+class Aggregate:
+    policy: str = ""
+    routes: tuple[str, ...] = ()  # "182.168.1.0/24",
+    export_policy: str = ""
+    as_path: str = ""
+    reference: str = ""
+    suppress: bool = False
+    discard: bool = True
+    as_set: bool = False
+
+
+@dataclass
+class Redistribute:
+    protocol: str
+    policy: str = ""
+
+
+@dataclass
+class FamilyOptions:
+    family: Family
+    vrf_name: str
+    multipath: int = 0
+    global_multipath: int = 0
+    aggregate: Aggregate = field(default_factory=Aggregate)
+    redistributes: tuple[Redistribute, ...] = ()
+    allow_default: bool = False
+    aspath_relax: bool = False
+    igp_ignore: bool = False
+    next_hop_policy: bool = False
+    rib_import_policy: bool = False
+    advertise_l2vpn_evpn: bool = False
+    rib_group: bool = False
+    loops: int = 0
+    advertise_bgp_static: bool = False
+
+
+@dataclass(frozen=True)
+class PeerGroup:
+    name: str
+    remote_as: ASN = ASN(None)
+    internal_name: str = ""
+    description: str = ""
+    update_source: str = ""
+
+    # more strict version of PeerOptions
+    local_as: ASN = ASN(None)
+    unnumbered: bool = False
+    rr_client: bool = False
+    next_hop_self: bool = False
+    extended_next_hop: bool = False
+    send_community: bool = False
+    send_lcommunity: bool = False
+    send_extcommunity: bool = False
+    send_labeled: bool = False
+    import_limit: bool = False
+    teardown_timeout: bool = False
+    redistribute: bool = False
+    passive: bool = False
+    mtu_discovery: bool = False
+    advertise_inactive: bool = False
+    advertise_bgp_static: bool = False
+    allowas_in: bool = False
+    auth_key: bool = False
+    add_path: bool = False
+    multipath: bool = False
+    multihop: bool = False
+    multihop_no_nexthop_change: bool = False
+    af_no_install: bool = False
+    bfd: bool = False
+    rib: bool = False
+    bfd_timers: Optional[BFDTimers] = None
+    resolve_vpn: bool = False
+    af_rib_group: Optional[str] = None
+    af_loops: int = 0
+    hold_time: int = 0
+    listen_network: bool = False
+    remove_private: bool = False
+    as_override: bool = False
+    aigp: bool = False
+    bmp_monitor: bool = False
+    no_prepend: bool = False
+    no_explicit_null: bool = False
+    uniq_iface: bool = False
+    advertise_peer_as: bool = False
+    connect_retry: bool = False
+    advertise_external: bool = False
+    advertise_irb: bool = False
+    listen_only: bool = False
+    soft_reconfiguration_inbound: bool = False
+    not_active: bool = False
+    mtu: int = 0
+
+
+@dataclass
+class VrfOptions:
+    vrf_name: str
+    vrf_name_global: Optional[str] = None
+    import_policy: Optional[str] = None
+    export_policy: Optional[str] = None
+    rt_import: list[str] = field(default_factory=list)
+    rt_export: list[str] = field(default_factory=list)
+    rt_import_v4: list[str] = field(default_factory=list)
+    rt_export_v4: list[str] = field(default_factory=list)
+    route_distinguisher: Optional[str] = None
+    static_label: Optional[int] = None  # FIXME: str?
+
+    ipv4_unicast: Optional[FamilyOptions] = None
+    ipv6_unicast: Optional[FamilyOptions] = None
+    ipv4_labeled_unicast: Optional[FamilyOptions] = None
+    ipv6_labeled_unicast: Optional[FamilyOptions] = None
+    groups: list[PeerGroup] = field(default_factory=list)
+
+
+@dataclass
+class GlobalOptions:
+    local_as: ASN = ASN(None)
+    loops: int = 0
+    multipath: int = 0
+    router_id: str = ""
+    vrf: dict[str, VrfOptions] = field(default_factory=dict)
+
+    ipv4_unicast: Optional[FamilyOptions] = None
+    ipv6_unicast: Optional[FamilyOptions] = None
+    ipv4_labeled_unicast: Optional[FamilyOptions] = None
+    ipv6_labeled_unicast: Optional[FamilyOptions] = None
+    groups: list[PeerGroup] = field(default_factory=list)
diff --git a/annet/configs/context.yml b/annet/configs/context.yml
index 7430844..2d91290 100644
--- a/annet/configs/context.yml
+++ b/annet/configs/context.yml
@@ -9,12 +9,11 @@ connection:
 generators:
   default:
     - annet_generators.example
+    - annet_generators.mesh_example
 
 storage:
   default:
-    adapter: file
-    params:
-      path: /path/to/file
+    adapter: netbox
 
 context:
   default:
diff --git a/annet/configs/logging.yaml b/annet/configs/logging.yaml
index 99706e3..4a70c0f 100644
--- a/annet/configs/logging.yaml
+++ b/annet/configs/logging.yaml
@@ -7,6 +7,7 @@ formatters:
         style: "{"
         datefmt: "%H:%M:%S"
         format: "[{asctime}] {log_color}{levelname:>7}{reset} {processName} - {pathname}:{lineno} - {message} -- {cyan}{_extra}{reset}"
+        max_vars_lines: 20
     host_progress:
         (): contextlog.SmartFormatter
         style: "{"
diff --git a/annet/mesh/__init__.py b/annet/mesh/__init__.py
new file mode 100644
index 0000000..27e8ba7
--- /dev/null
+++ b/annet/mesh/__init__.py
@@ -0,0 +1,16 @@
+__all__ = [
+    "GlobalOptions",
+    "MeshSession",
+    "DirectPeer",
+    "IndirectPeer",
+    "MeshExecutor",
+    "MeshRulesRegistry",
+    "Left",
+    "Right",
+    "Match",
+]
+
+from .executor import MeshExecutor
+from .match_args import Left, Right, Match
+from .registry import DirectPeer, IndirectPeer, MeshSession, GlobalOptions
+from .registry import MeshRulesRegistry
diff --git a/annet/mesh/basemodel.py b/annet/mesh/basemodel.py
new file mode 100644
index 0000000..f502df1
--- /dev/null
+++ b/annet/mesh/basemodel.py
@@ -0,0 +1,180 @@
+from abc import ABC
+from copy import copy
+from enum import Enum
+from typing import (
+    TypeVar, Any, Annotated, get_origin, get_type_hints, get_args, Callable, Union, ClassVar, overload,
+)
+
+
+class MergeForbiddenError(Exception):
+    pass
+
+
+class Special(Enum):
+    NOT_SET = "<NOT SET>"
+
+
+T = TypeVar("T")
+
+
+class Merger(ABC):
+    def __call__(self, name: str, x: Union[T, Special], y: Union[T, Special]) -> Union[T, Special]:
+        if x is Special.NOT_SET:
+            return y
+        if y is Special.NOT_SET:
+            return x
+        return self._merge(name, x, y)
+
+    def _merge(self, name: str, x: T, y: T) -> T:
+        raise NotImplementedError
+
+
+class UseFirst(Merger):
+    def _merge(self, name: str, x: T, y: T) -> T:
+        return x
+
+
+class UseLast(Merger):
+    def _merge(self, name: str, x: T, y: T) -> T:
+        return y
+
+
+class Forbid(Merger):
+    def _merge(self, name: str, x: T, y: T) -> T:
+        raise MergeForbiddenError(f"Override is forbidden for field {name}")
+
+
+class ForbidChange(Merger):
+    def _merge(self, name: str, x: T, y: T) -> T:
+        if x == y:
+            return x
+        raise MergeForbiddenError(
+            f"Override with different value is forbidden for field {name}:\n"
+            f"Old: {x}\n"
+            f"New: {y}"
+        )
+
+
+class Concat(Merger):
+    def _merge(self, name: str, x: T, y: T) -> T:
+        return x + y  # type: ignore[operator]
+
+
+class Merge(Merger):
+    def _merge(self, name: str, x: "ModelT", y: "ModelT") -> "ModelT":  # type: ignore[override]
+        return merge(x, y)
+
+
+class DictMerge(Merger):
+    def __init__(self, value_merger: Merger = Forbid()):
+        self.value_merger = value_merger
+
+    def _merge(self, name: str, x: dict, y: dict) -> dict:  # type: ignore[override]
+        result = copy(x)
+        for key, value in y.items():
+            if key in result:
+                result[key] = self.value_merger(key, result[key], value)
+            else:
+                result[key] = value
+        return result
+
+
+class ApplyFunc(Merger):
+    def __init__(self, func: Callable):
+        self.func = func
+
+    def __call__(self, name: str, x: Union[T, Special], y: Union[T, Special]) -> Union[T, Special]:
+        if x is Special.NOT_SET:
+            return y
+        if y is Special.NOT_SET:
+            return x
+        return self.func(x, y)
+
+
+def _get_merger(hint: Any):
+    if get_origin(hint) is not Annotated:
+        return ForbidChange()
+    for arg in get_args(hint):
+        if isinstance(arg, Merger):
+            return arg
+    return ForbidChange()
+
+
+class BaseMeshModel:
+    _field_mergers: ClassVar[dict[str, Merger]]
+
+    def __init__(self, **kwargs):
+        if extra_keys := (kwargs.keys() - self._field_mergers.keys()):
+            raise ValueError(f"Extra arguments: {extra_keys}")
+        self.__dict__.update(kwargs)
+
+    def unset_attrs(self):
+        return type(self)._field_mergers.keys() - vars(self).keys()
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}(" + ", ".join(
+            f"{key}={value}" for key, value in vars(self).items()
+        ) + ")"
+
+    def __init_subclass__(cls, **kwargs):
+        cls._field_mergers = {
+            field: _get_merger(hint)
+            for field, hint in get_type_hints(cls, include_extras=True).items()
+            if get_origin(hint) is not ClassVar
+        }
+
+    def __setattr__(self, key, value):
+        if key not in self._field_mergers:
+            raise AttributeError(f"{self.__class__.__name__} has no field {key}")
+        super().__setattr__(key, value)
+
+
+ModelT = TypeVar("ModelT", bound=BaseMeshModel)
+
+
+def _merge(a: ModelT, b: BaseMeshModel) -> ModelT:
+    result = copy(a)
+    for attr_name, merger in a._field_mergers.items():
+        new_value = merger(
+            attr_name,
+            getattr(a, attr_name, Special.NOT_SET),
+            getattr(b, attr_name, Special.NOT_SET),
+        )
+        if new_value is not Special.NOT_SET:
+            setattr(result, attr_name, new_value)
+    return result
+
+
+@overload
+def merge(first: Special, second: ModelT, /, *others: BaseMeshModel) -> ModelT:
+    ...
+
+
+@overload
+def merge(first: ModelT, /, *others: BaseMeshModel) -> ModelT:
+    ...
+
+
+@overload
+def merge(first: Special, /) -> Special:
+    ...
+
+
+def merge(first: Any, /, *others: Any) -> Any:
+    if first is Special.NOT_SET:
+        if not others:
+            return Special.NOT_SET
+        return merge(*others)
+    for second in others:
+        first = _merge(first, second)
+    return first
+
+
+class KeyDefaultDict(dict):
+    def __init__(self, factory: Callable[[str], Any]):
+        super().__init__()
+        self.factory = factory
+
+    def __missing__(self, key):
+        x = self[key] = self.factory(key)
+        return x
diff --git a/annet/mesh/device_models.py b/annet/mesh/device_models.py
new file mode 100644
index 0000000..8ad3fea
--- /dev/null
+++ b/annet/mesh/device_models.py
@@ -0,0 +1,62 @@
+from typing import Annotated, Optional, Union
+
+from annet.bgp_models import Family, Aggregate, Redistribute
+from .basemodel import BaseMeshModel, Concat, DictMerge, Merge, KeyDefaultDict
+from .peer_models import MeshPeerGroup
+
+
+class FamilyOptions(BaseMeshModel):
+    family: Family
+    vrf_name: str
+    multipath: int = 0
+    global_multipath: int
+    aggregate: Aggregate
+    redistributes: Annotated[tuple[Redistribute, ...], Concat()]
+    allow_default: bool
+    aspath_relax: bool
+    igp_ignore: bool
+    next_hop_policy: bool
+    rib_import_policy: bool
+    advertise_l2vpn_evpn: bool
+    rib_group: bool
+    loops: int
+    advertise_bgp_static: bool
+
+
+class _FamiliesMixin:
+    ipv4_unicast: Optional[FamilyOptions]
+    ipv6_unicast: Optional[FamilyOptions]
+    ipv4_labeled_unicast: Optional[FamilyOptions]
+    ipv6_labeled_unicast: Optional[FamilyOptions]
+
+
+class VrfOptions(BaseMeshModel, _FamiliesMixin):
+    def __init__(self, **kwargs):
+        kwargs.setdefault("groups", KeyDefaultDict(lambda x: MeshPeerGroup(name=x)))
+        super().__init__(**kwargs)
+
+    vrf_name: str
+    vrf_name_global: Optional[str]
+    import_policy: Optional[str]
+    export_policy: Optional[str]
+    rt_import: Annotated[tuple[str, ...], Concat()]
+    rt_export: Annotated[tuple[str, ...], Concat()]
+    rt_import_v4: Annotated[tuple[str, ...], Concat()]
+    rt_export_v4: Annotated[tuple[str, ...], Concat()]
+    route_distinguisher: Optional[str]
+    static_label: Optional[int]  # FIXME: str?
+    groups: Annotated[dict[str, MeshPeerGroup], DictMerge(Merge())]
+
+
+class GlobalOptionsDTO(BaseMeshModel, _FamiliesMixin):
+    def __init__(self, **kwargs):
+        kwargs.setdefault("groups", KeyDefaultDict(lambda x: MeshPeerGroup(name=x)))
+        kwargs.setdefault("vrf", KeyDefaultDict(lambda x: VrfOptions(vrf_name=x)))
+        super().__init__(**kwargs)
+
+    local_as: Union[int, str]
+    loops: int
+    multipath: int
+    router_id: str
+    vrf: Annotated[dict[str, VrfOptions], DictMerge(Merge())]
+    groups: Annotated[dict[str, MeshPeerGroup], DictMerge(Merge())]
diff --git a/annet/mesh/executor.py b/annet/mesh/executor.py
new file mode 100644
index 0000000..a59c449
--- /dev/null
+++ b/annet/mesh/executor.py
@@ -0,0 +1,248 @@
+from dataclasses import dataclass
+from logging import getLogger
+from typing import Annotated, Callable, Optional
+
+from annet.bgp_models import Peer, GlobalOptions
+from annet.storage import Device, Storage
+from .basemodel import merge, BaseMeshModel, Merge, UseLast, MergeForbiddenError
+from .device_models import GlobalOptionsDTO
+from .models_converter import to_bgp_global_options, to_bgp_peer, InterfaceChanges, to_interface_changes
+from .peer_models import PeerDTO
+from .registry import MeshRulesRegistry, GlobalOptions as MeshGlobalOptions, DirectPeer, MeshSession, IndirectPeer
+
+
+logger = getLogger(__name__)
+
+
+@dataclass
+class MeshExecutionResult:
+    global_options: GlobalOptions
+    peers: list[Peer]
+
+
+@dataclass(frozen=True)
+class PeerKey:
+    fqdn: str
+    addr: str
+    vrf: str
+
+
+class Pair(BaseMeshModel):
+    local: Annotated[PeerDTO, Merge()]
+    connected: Annotated[PeerDTO, Merge()]
+    device: Annotated[Device, UseLast()]
+
+
+class MeshExecutor:
+    def __init__(
+            self,
+            registry: MeshRulesRegistry,
+            storage: Storage,
+    ):
+        self._registry = registry
+        self._storage = storage
+
+    def _execute_globals(self, device: Device) -> GlobalOptionsDTO:
+        global_opts = GlobalOptionsDTO()
+        for rule in self._registry.lookup_global(device.fqdn):
+            handler_name = self._handler_name(rule.handler)
+            rule_global_opts = MeshGlobalOptions(rule.match, device)
+            logger.debug("Running device handler: %s", handler_name)
+            rule.handler(rule_global_opts)
+            try:
+                global_opts = merge(global_opts, rule_global_opts)
+            except MergeForbiddenError as e:
+                raise ValueError(
+                    f"Handler `{handler_name}` global options conflicting with "
+                    f"previously loaded for device `{device.fqdn}`:\n" + str(e)
+                ) from e
+        return global_opts
+
+    def _handler_name(self, handler: Callable) -> str:
+        try:
+            return f"{handler.__module__}.{handler.__qualname__}"
+        except AttributeError:
+            return str(handler)
+
+    def _execute_direct(self, device: Device) -> list[Pair]:
+        # we can have multiple rules for the same pair
+        # we merge them according to remote fqdn
+        neighbor_peers: dict[PeerKey, Pair] = {}
+        # TODO batch resolve
+        for rule in self._registry.lookup_direct(device.fqdn, device.neighbours_fqdns):
+            session = MeshSession()
+            handler_name = self._handler_name(rule.handler)
+            logger.debug("Running direct handler: %s", handler_name)
+            if rule.direct_order:
+                neighbor_device = self._storage.make_devices([rule.name_right])[0]
+                peer_device = DirectPeer(rule.match_left, device, [])
+                peer_neighbor = DirectPeer(rule.match_right, neighbor_device, [])
+            else:
+                neighbor_device = self._storage.make_devices([rule.name_left])[0]
+                peer_neighbor = DirectPeer(rule.match_left, neighbor_device, [])
+                peer_device = DirectPeer(rule.match_right, device, [])
+
+            interfaces = self._storage.search_connections(device, neighbor_device)
+            for local_port, remote_port in interfaces:
+                peer_device.ports.append(local_port.name)
+                peer_neighbor.ports.append(remote_port.name)
+
+            if rule.direct_order:
+                rule.handler(peer_device, peer_neighbor, session)
+            else:
+                rule.handler(peer_neighbor, peer_device, session)
+
+            try:
+                neighbor_dto = merge(PeerDTO(), peer_neighbor, session)
+            except MergeForbiddenError as e:
+                raise ValueError(
+                    f"Handler `{handler_name}` provided session data conflicting with "
+                    f"peer data for device `{neighbor_device.fqdn}`:\n" + str(e)
+                ) from e
+            try:
+                device_dto = merge(PeerDTO(), peer_device, session)
+            except MergeForbiddenError as e:
+                raise ValueError(
+                    f"Handler `{handler_name}` provided session data conflicting with "
+                    f"peer data for device `{device.fqdn}`:\n" + str(e)
+                ) from e
+
+            pair = Pair(local=device_dto, connected=neighbor_dto, device=neighbor_device)
+            addr = getattr(neighbor_dto, "addr", None)
+            if addr is None:
+                raise ValueError(f"Handler `{handler_name}` returned no peer addr")
+            peer_key = PeerKey(
+                fqdn=neighbor_device.fqdn,
+                addr=addr,
+                vrf=getattr(neighbor_dto, "vrf", "")
+            )
+            try:
+                if peer_key in neighbor_peers:
+                    pair = merge(neighbor_peers[peer_key], pair)
+            except MergeForbiddenError as e:
+                if rule.direct_order:
+                    pair_names = device.fqdn, neighbor_device.fqdn
+                else:
+                    pair_names = neighbor_device.fqdn, device.fqdn
+                raise ValueError(
+                    f"Handler `{handler_name}` provides data conflicting with "
+                    f"previously loaded for device pair {pair_names} "
+                    f"with addr={peer_key.addr}, vrf{peer_key.vrf}:\n" + str(e)
+                ) from e
+            neighbor_peers[peer_key] = pair
+        return list(neighbor_peers.values())
+
+    def _execute_indirect(self, device: Device, all_fqdns: list[str]) -> list[Pair]:
+        # we can have multiple rules for the same pair
+        # we merge them according to remote fqdn
+        connected_peers: dict[PeerKey, Pair] = {}
+        for rule in self._registry.lookup_indirect(device.fqdn, all_fqdns):
+            session = MeshSession()
+            handler_name = self._handler_name(rule.handler)
+            logger.debug("Running indirect handler: %s", handler_name)
+            if rule.direct_order:
+                connected_device = self._storage.make_devices([rule.name_right])[0]
+                peer_device = IndirectPeer(rule.match_left, device)
+                peer_connected = IndirectPeer(rule.match_right, connected_device)
+                rule.handler(peer_device, peer_connected, session)
+            else:
+                connected_device = self._storage.make_devices([rule.name_left])[0]
+                peer_connected = IndirectPeer(rule.match_left, connected_device)
+                peer_device = IndirectPeer(rule.match_right, device)
+                rule.handler(peer_connected, peer_device, session)
+
+            try:
+                connected_dto = merge(PeerDTO(), peer_connected, session)
+            except MergeForbiddenError as e:
+                raise ValueError(
+                    f"Handler `{handler_name}` provided session data conflicting with "
+                    f"peer data for device `{connected_device.fqdn}`:\n" + str(e)
+                ) from e
+            try:
+                device_dto = merge(PeerDTO(), peer_device, session)
+            except MergeForbiddenError as e:
+                raise ValueError(
+                    f"Handler `{handler_name}` provided session data conflicting with "
+                    f"peer data for device `{device.fqdn}`:\n" + str(e)
+                ) from e
+
+            pair = Pair(local=device_dto, connected=connected_dto, device=connected_device)
+            addr = getattr(connected_dto, "addr", None)
+            if addr is None:
+                raise ValueError(f"Handler `{handler_name}` returned no peer addr")
+            peer_key = PeerKey(
+                fqdn=connected_device.fqdn,
+                addr=addr,
+                vrf=getattr(connected_dto, "vrf", "")
+            )
+            try:
+                if peer_key in connected_peers:
+                    pair = merge(connected_peers[peer_key], pair)
+            except MergeForbiddenError as e:
+                if rule.direct_order:
+                    pair_names = device.fqdn, connected_device.fqdn
+                else:
+                    pair_names = connected_device.fqdn, device.fqdn
+                raise ValueError(
+                    f"Handler `{handler_name}` provides data conflicting with "
+                    f"previously loaded for device pair {pair_names} "
+                    f"with addr={peer_key.addr}, vrf{peer_key.vrf}:\n" + str(e)
+                ) from e
+            connected_peers[peer_key] = pair
+
+        return list(connected_peers.values())  # FIXME
+
+    def _to_bgp_peer(self, pair: Pair, interface: Optional[str]) -> Peer:
+        return to_bgp_peer(pair.local, pair.connected, pair.device, interface)
+
+    def _to_bgp_global(self, global_options: GlobalOptionsDTO) -> GlobalOptions:
+        return to_bgp_global_options(global_options)
+
+    def _apply_interface_changes(self, device: Device, neighbor: Device, changes: InterfaceChanges) -> str:
+        port_pairs = self._storage.search_connections(device, neighbor)
+        if len(port_pairs) > 1:
+            if changes.lag is changes.svi is None:
+                raise ValueError(
+                    f"Multiple connections found between {device.fqdn} and {neighbor.fqdn}."
+                    "Specify LAG or SVI"
+                )
+        if changes.lag is not None:
+            target_interface = device.make_lag(
+                lag=changes.lag,
+                ports=[local_port.name for local_port, remote_port in port_pairs],
+                lag_min_links=changes.lag_links_min,
+            )
+            if changes.subif is not None:
+                target_interface = device.add_subif(target_interface.name, changes.subif)
+        elif changes.subif is not None:
+            # single connection
+            local_port, remote_port = port_pairs[0]
+            target_interface = device.add_subif(local_port.name, changes.subif)
+        elif changes.svi is not None:
+            target_interface = device.add_svi(changes.svi)
+        else:
+            target_interface, _ = port_pairs[0]
+        target_interface.add_addr(changes.addr, changes.vrf)
+        return target_interface.name
+
+    def execute_for(self, device: Device) -> MeshExecutionResult:
+        all_fqdns = self._storage.resolve_all_fdnds()
+
+        global_options = self._to_bgp_global(self._execute_globals(device))
+
+        peers = []
+        for direct_pair in self._execute_direct(device):
+            target_interface = self._apply_interface_changes(
+                device,
+                direct_pair.device,
+                to_interface_changes(direct_pair.local),
+            )
+            peers.append(self._to_bgp_peer(direct_pair, target_interface))
+
+        for connected_pair in self._execute_indirect(device, all_fqdns):
+            peers.append(self._to_bgp_peer(connected_pair, None))
+
+        return MeshExecutionResult(
+            global_options=global_options,
+            peers=peers,
+        )
diff --git a/annet/mesh/match_args.py b/annet/mesh/match_args.py
new file mode 100644
index 0000000..719d82d
--- /dev/null
+++ b/annet/mesh/match_args.py
@@ -0,0 +1,165 @@
+import re
+from collections.abc import Callable
+from dataclasses import dataclass
+from types import SimpleNamespace
+from typing import Any, Sequence, Optional
+
+MatchedArgs = SimpleNamespace
+
+
+def identity(x: Any) -> Any:
+    return x
+
+
+class MatchExpr:
+    def __init__(self, expr: Callable[[Any], Any] = identity):
+        self.expr = expr
+
+    def __getattr__(self, item: str):
+        return MatchExpr(lambda x: getattr(self.expr(x), item))
+
+    def __getitem__(self, item: Any):
+        return MatchExpr(lambda x: self.expr(x)[item])
+
+    def __eq__(self, other) -> "MatchExpr":  # type: ignore[override]  # https://github.com/python/mypy/issues/5951
+        if isinstance(other, MatchExpr):
+            return MatchExpr(lambda x: self.expr(x) == other.expr(x))
+        else:
+            return MatchExpr(lambda x: self.expr(x) == other)
+
+    def __ne__(self, other) -> "MatchExpr":  # type: ignore[override]  # https://github.com/python/mypy/issues/5951
+        if isinstance(other, MatchExpr):
+            return MatchExpr(lambda x: self.expr(x) != other.expr(x))
+        else:
+            return MatchExpr(lambda x: self.expr(x) != other)
+
+    def __lt__(self, other) -> "MatchExpr":
+        if isinstance(other, MatchExpr):
+            return MatchExpr(lambda x: self.expr(x) < other.expr(x))
+        else:
+            return MatchExpr(lambda x: self.expr(x) < other)
+
+    def __gt__(self, other) -> "MatchExpr":
+        if isinstance(other, MatchExpr):
+            return MatchExpr(lambda x: self.expr(x) > other.expr(x))
+        else:
+            return MatchExpr(lambda x: self.expr(x) > other)
+
+    def __le__(self, other) -> "MatchExpr":
+        if isinstance(other, MatchExpr):
+            return MatchExpr(lambda x: self.expr(x) <= other.expr(x))
+        else:
+            return MatchExpr(lambda x: self.expr(x) <= other)
+
+    def __ge__(self, other) -> "MatchExpr":
+        if isinstance(other, MatchExpr):
+            return MatchExpr(lambda x: self.expr(x) >= other.expr(x))
+        else:
+            return MatchExpr(lambda x: self.expr(x) >= other)
+
+    def __or__(self, other: "MatchExpr") -> "MatchExpr":
+        return MatchExpr(lambda x: self.expr(x) or other.expr(x))
+
+    def __and__(self, other: "MatchExpr") -> "MatchExpr":
+        return MatchExpr(lambda x: self.expr(x) and other.expr(x))
+
+    def cast_(self, type_: Callable[[Any], Any]) -> "MatchExpr":
+        return MatchExpr(lambda x: type_(self.expr(x)))
+
+    def in_(self, value: Any) -> "MatchExpr":
+        if isinstance(value, MatchExpr):
+            return MatchExpr(lambda x: self.expr(x) in value.expr(x))
+        else:
+            return MatchExpr(lambda x: self.expr(x) in value)
+
+
+Match = MatchExpr()[0]
+Left = MatchExpr()[0]
+Right = MatchExpr()[1]
+
+
+class PeerNameTemplate:
+    def __init__(self, raw_str):
+        self._str = str(raw_str)
+        self._regex, self._types = self._compile(self._str)
+
+    def __str__(self):
+        return self._str
+
+    @staticmethod
+    def _compile(value: str) -> tuple[re.Pattern[str], dict[str, type]]:
+        int_groups = re.findall(r"{(?P<group_name>\w+)}", value)
+        # '{name}'  -> (?P<name>\d+)
+        regex_string = re.sub(r"{(?P<group_name>\w+)}", r"(?P<\g<group_name>>\\d+)", value)
+        # '{name:regex}' -> (?P<name>regex)
+        regex_string = re.sub(
+            r"{(?P<group_name>\w+):(?P<custom_regex>.*?)}", r"(?P<\g<group_name>>\g<custom_regex>)",
+            regex_string
+        )
+        pattern = re.compile(regex_string)
+        types: dict[str, type] = {
+            name: (int if name in int_groups else str)
+            for name in pattern.groupindex
+        }
+        return pattern, types
+
+    def match(self, hostname: str) -> Optional[dict[str, str]]:
+        reg_match = self._regex.fullmatch(hostname)
+        if reg_match:
+            return {
+                key: self._types[key](value)
+                for key, value in reg_match.groupdict().items()
+            }
+        return None
+
+
+def match_safe(match_expressions: Sequence[MatchExpr], value: Any) -> bool:
+    for matcher in match_expressions:
+        try:
+            res = matcher.expr(value)
+            if not bool(res):
+                return False
+        except (TypeError, ValueError, AttributeError, KeyError, IndexError):
+            return False
+    return True
+
+
+@dataclass
+class SingleMatcher:
+    def __init__(self, rule: str, match_expressions: Sequence[MatchExpr]):
+        self.rule = PeerNameTemplate(rule)
+        self.match_expressions = match_expressions
+
+    def match_one(self, host: str) -> Optional[MatchedArgs]:
+        data = self.rule.match(host)
+        if data is None:
+            return None
+        args = MatchedArgs(**data)
+        if not match_safe(self.match_expressions, (args,)):
+            return None
+        return args
+
+
+@dataclass
+class PairMatcher:
+    def __init__(self, left_rule: str, right_rule: str, match_expressions: Sequence[MatchExpr]):
+        self.left_rule = PeerNameTemplate(left_rule)
+        self.right_rule = PeerNameTemplate(right_rule)
+        self.match_expressions = match_expressions
+
+    def match_pair(self, left: str, right: str) -> Optional[tuple[MatchedArgs, MatchedArgs]]:
+        left_args = self._match_host(self.left_rule, left)
+        if left_args is None:
+            return None
+        right_args = self._match_host(self.right_rule, right)
+        if right_args is None:
+            return None
+        if not match_safe(self.match_expressions, (left_args, right_args)):
+            return None
+        return left_args, right_args
+
+    def _match_host(self, rule: PeerNameTemplate, host: str) -> Optional[MatchedArgs]:
+        data = rule.match(host)
+        if data is None:
+            return None
+        return MatchedArgs(**data)
diff --git a/annet/mesh/models_converter.py b/annet/mesh/models_converter.py
new file mode 100644
index 0000000..a761c6a
--- /dev/null
+++ b/annet/mesh/models_converter.py
@@ -0,0 +1,84 @@
+from dataclasses import dataclass
+from ipaddress import ip_interface
+from typing import Optional
+
+from adaptix import Retort, loader, Chain, name_mapping
+
+from .peer_models import PeerDTO
+from ..bgp_models import GlobalOptions, VrfOptions, FamilyOptions, Peer, PeerGroup, ASN, PeerOptions
+from ..storage import Device
+
+
+@dataclass
+class InterfaceChanges:
+    addr: str
+    lag: Optional[int] = None
+    lag_links_min: Optional[int] = None
+    svi: Optional[int] = None
+    subif: Optional[int] = None
+    vrf: Optional[str] = None
+
+    def __post_init__(self):
+        if self.lag is not None and self.svi is not None:
+            raise ValueError("Cannot use LAG and SVI together")
+        if self.svi is not None and self.subif is not None:
+            raise ValueError("Cannot use Subif and SVI together")
+
+
+class ObjMapping:
+    def __init__(self, obj):
+        self.obj = obj
+
+    def __contains__(self, item):
+        return hasattr(self.obj, item)
+
+    def __getitem__(self, name):
+        return getattr(self.obj, name)
+
+    def get(self, name, default=None):
+        return getattr(self.obj, name, default)
+
+
+retort = Retort(
+    recipe=[
+        loader(InterfaceChanges, ObjMapping, Chain.FIRST),
+        loader(ASN, ASN),
+        loader(GlobalOptions, ObjMapping, Chain.FIRST),
+        loader(VrfOptions, ObjMapping, Chain.FIRST),
+        loader(FamilyOptions, ObjMapping, Chain.FIRST),
+        loader(PeerOptions, ObjMapping, Chain.FIRST),
+        name_mapping(PeerOptions, map={
+            "local_as": "asnum",
+        }),
+        loader(list[PeerGroup], lambda x: list(x.values()), Chain.FIRST),
+        loader(PeerGroup, ObjMapping, Chain.FIRST),
+        name_mapping(PeerGroup, map={
+            "local_as": "asnum",
+        }),
+    ]
+)
+
+to_bgp_global_options = retort.get_loader(GlobalOptions)
+to_interface_changes = retort.get_loader(InterfaceChanges)
+
+
+def to_bgp_peer(local: PeerDTO, connected: PeerDTO, connected_device: Device, interface: Optional[str]) -> Peer:
+    options = retort.load(local, PeerOptions)
+    # TODO validate `lagg_links` before conversion
+    result = Peer(
+        addr=str(ip_interface(connected.addr).ip),
+        interface=interface,
+        remote_as=ASN(connected.asnum),
+        hostname=connected_device.hostname,
+        options=options,
+    )
+    # connected
+    result.vrf_name = getattr(connected, "vrf", result.vrf_name)
+    result.group_name = getattr(connected, "group_name", result.group_name)
+    result.description = getattr(connected, "description", result.description)
+    result.families = getattr(connected, "families", result.families)
+    # local
+    result.import_policy = getattr(connected, "import_policy", result.import_policy)
+    result.export_policy = getattr(connected, "export_policy", result.export_policy)
+    result.update_source = getattr(connected, "update_source", result.update_source)
+    return result
diff --git a/annet/mesh/peer_models.py b/annet/mesh/peer_models.py
new file mode 100644
index 0000000..6d29cd3
--- /dev/null
+++ b/annet/mesh/peer_models.py
@@ -0,0 +1,98 @@
+from typing import Literal, Annotated, Union, Optional
+
+from .basemodel import BaseMeshModel, Concat
+from ..bgp_models import BFDTimers
+
+FamilyName = Literal["ipv4_unicast", "ipv6_unicast", "ipv4_labeled", "ipv6_labeled"]
+
+
+class _SharedOptionsDTO(BaseMeshModel):
+    """
+    Options which can be set on connected pair or group of peers
+    """
+    add_path: bool
+    multipath: bool
+    advertise_irb: bool
+    send_labeled: bool
+    send_community: bool
+    bfd: bool
+    bfd_timers: BFDTimers
+
+
+class MeshSession(_SharedOptionsDTO):
+    """
+    Options which are set on connected pair
+    """
+    asnum: Union[int, str]
+    vrf: str
+    families: Annotated[set[FamilyName], Concat()]
+    group_name: str
+
+    bmp_monitor: bool
+
+    import_policy: str
+    export_policy: str
+
+
+class _OptionsDTO(_SharedOptionsDTO):
+    """
+    Options which can be set on group of peers or peer itself
+    """
+    unnumbered: bool
+    rr_client: bool
+    next_hop_self: bool
+    extended_next_hop: bool
+    send_lcommunity: bool
+    send_extcommunity: bool
+    import_limit: bool
+    teardown_timeout: bool
+    redistribute: bool
+    passive: bool
+    mtu_discovery: bool
+    advertise_inactive: bool
+    advertise_bgp_static: bool
+    allowas_in: bool
+    auth_key: bool
+    multihop: bool
+    multihop_no_nexthop_change: bool
+    af_no_install: bool
+    rib: bool
+    resolve_vpn: bool
+    af_rib_group: Optional[str]
+    af_loops: int
+    hold_time: int
+    listen_network: bool
+    remove_private: bool
+    as_override: bool
+    aigp: bool
+    no_prepend: bool
+    no_explicit_null: bool
+    uniq_iface: bool
+    advertise_peer_as: bool
+    connect_retry: bool
+    advertise_external: bool
+    listen_only: bool
+    soft_reconfiguration_inbound: bool
+    not_active: bool
+    mtu: int
+
+
+class PeerDTO(MeshSession, _OptionsDTO):
+    pod: int
+    addr: str
+    description: str
+
+    subif: int
+    lag: Optional[int]
+    lag_links_min: Optional[int]
+    svi: Optional[int]
+
+    group_name: str
+
+
+class MeshPeerGroup(_OptionsDTO):
+    name: str
+    remote_as: Union[int, str]
+    internal_name: str
+    update_source: str
+    description: str
diff --git a/annet/mesh/registry.py b/annet/mesh/registry.py
new file mode 100644
index 0000000..6615e69
--- /dev/null
+++ b/annet/mesh/registry.py
@@ -0,0 +1,212 @@
+from dataclasses import dataclass
+from typing import Callable, Any
+
+from .match_args import MatchExpr, PairMatcher, SingleMatcher
+from .match_args import MatchedArgs
+from .device_models import GlobalOptionsDTO
+from .peer_models import PeerDTO, MeshSession
+
+
+class DirectPeer(PeerDTO):
+    match: MatchedArgs
+    device: Any
+    ports: list[str]
+
+    def __init__(self, match: MatchedArgs, device: Any, ports: list[str]) -> None:
+        super().__init__()
+        self.match = match
+        self.device = device
+        self.ports = ports
+
+
+class IndirectPeer(PeerDTO):
+    match: MatchedArgs
+    device: Any
+
+    def __init__(self, match: MatchedArgs, device: Any) -> None:
+        super().__init__()
+        self.match = match
+        self.device = device
+
+
+class GlobalOptions(GlobalOptionsDTO):
+    match: MatchedArgs
+    device: Any
+
+    def __init__(self, match: MatchedArgs, device: Any) -> None:
+        super().__init__()
+        self.match = match
+        self.device = device
+
+
+GlobalHandler = Callable[[GlobalOptions], None]
+
+
+@dataclass
+class GlobalRule:
+    matcher: SingleMatcher
+    handler: GlobalHandler
+
+
+DirectHandler = Callable[[DirectPeer, DirectPeer, MeshSession], None]
+IndirectHandler = Callable[[IndirectPeer, IndirectPeer, MeshSession], None]
+
+
+@dataclass
+class DirectRule:
+    __slots__ = ("matcher", "handler")
+    matcher: PairMatcher
+    handler: DirectHandler
+
+
+@dataclass
+class IndirectRule:
+    __slots__ = ("matcher", "handler")
+    matcher: PairMatcher
+    handler: IndirectHandler
+
+
+@dataclass
+class MatchedGlobal:
+    __slots__ = ("match", "handler")
+    match: MatchedArgs
+    handler: GlobalHandler
+
+
+@dataclass
+class MatchedDirectPair:
+    __slots__ = ("handler", "direct_order", "name_left", "name_right", "match_left", "match_right")
+    handler: DirectHandler
+    direct_order: bool
+    name_left: str
+    name_right: str
+    match_left: MatchedArgs
+    match_right: MatchedArgs
+
+
+@dataclass
+class MatchedIndirectPair:
+    __slots__ = ("handler", "direct_order", "name_left", "name_right", "match_left", "match_right")
+    handler: IndirectHandler
+    direct_order: bool
+    name_left: str
+    name_right: str
+    match_left: MatchedArgs
+    match_right: MatchedArgs
+
+
+class MeshRulesRegistry:
+    def __init__(self, match_short_name: bool = False):
+        self.direct_rules: list[DirectRule] = []
+        self.indirect_rules: list[IndirectRule] = []
+        self.global_rules: list[GlobalRule] = []
+        self.nested: list[MeshRulesRegistry] = []
+        self.match_short_name = match_short_name
+
+    def _normalize_host(self, host: str) -> str:
+        if self.match_short_name:
+            return host.split(".", maxsplit=1)[0]
+        return host
+
+    def include(self, nested_registry: "MeshRulesRegistry") -> None:
+        self.nested.append(nested_registry)
+
+    def device(self, peer_mask: str, *match: MatchExpr) -> Callable[[GlobalHandler], GlobalHandler]:
+        matcher = SingleMatcher(peer_mask, match)
+
+        def register(handler: GlobalHandler) -> GlobalHandler:
+            self.global_rules.append(GlobalRule(matcher, handler))
+            return handler
+
+        return register
+
+    def direct(
+            self, left_mask: str, right_mask: str, *match: MatchExpr,
+    ) -> Callable[[DirectHandler], DirectHandler]:
+        matcher = PairMatcher(left_mask, right_mask, match)
+
+        def register(handler: DirectHandler) -> DirectHandler:
+            self.direct_rules.append(DirectRule(matcher, handler))
+            return handler
+
+        return register
+
+    def indirect(
+            self, left_mask: str, right_mask: str, *match: MatchExpr,
+    ) -> Callable[[IndirectHandler], IndirectHandler]:
+        matcher = PairMatcher(left_mask, right_mask, match)
+
+        def register(handler: IndirectHandler) -> IndirectHandler:
+            self.indirect_rules.append(IndirectRule(matcher, handler))
+            return handler
+
+        return register
+
+    def lookup_direct(self, device: str, neighbors: list[str]) -> list[MatchedDirectPair]:
+        found = []
+        device = self._normalize_host(device)
+        for neighbor in neighbors:
+            neighbor = self._normalize_host(neighbor)
+            for rule in self.direct_rules:
+                if args := rule.matcher.match_pair(device, neighbor):
+                    found.append(MatchedDirectPair(
+                        handler=rule.handler,
+                        direct_order=True,
+                        name_left=device,
+                        name_right=neighbor,
+                        match_left=args[0],
+                        match_right=args[1],
+                    ))
+                if args := rule.matcher.match_pair(neighbor, device):
+                    found.append(MatchedDirectPair(
+                        handler=rule.handler,
+                        direct_order=False,
+                        name_left=neighbor,
+                        name_right=device,
+                        match_left=args[0],
+                        match_right=args[1],
+                    ))
+        for registry in self.nested:
+            found.extend(registry.lookup_direct(device, neighbors))
+        return found
+
+    def lookup_indirect(self, device: str, devices: list[str]) -> list[MatchedIndirectPair]:
+        found = []
+        device = self._normalize_host(device)
+        for other_device in devices:
+            other_device = self._normalize_host(other_device)
+            for rule in self.indirect_rules:
+                if args := rule.matcher.match_pair(device, other_device):
+                    found.append(MatchedIndirectPair(
+                        handler=rule.handler,
+                        direct_order=True,
+                        name_left=device,
+                        name_right=other_device,
+                        match_left=args[0],
+                        match_right=args[1],
+                    ))
+                if args := rule.matcher.match_pair(other_device, device):
+                    found.append(MatchedIndirectPair(
+                        handler=rule.handler,
+                        direct_order=False,
+                        name_left=other_device,
+                        name_right=device,
+                        match_left=args[0],
+                        match_right=args[1],
+                    ))
+        for registry in self.nested:
+            found.extend(registry.lookup_indirect(device, devices))
+        return found
+
+    def lookup_global(self, device: str) -> list[MatchedGlobal]:
+        found = []
+        device = self._normalize_host(device)
+        for rule in self.global_rules:
+            if args := rule.matcher.match_one(device):
+                found.append(MatchedGlobal(
+                    handler=rule.handler,
+                    match=args,
+                ))
+        for registry in self.nested:
+            found.extend(registry.lookup_global(device))
+        return found
diff --git a/annet/storage.py b/annet/storage.py
index c66c169..3b127e2 100644
--- a/annet/storage.py
+++ b/annet/storage.py
@@ -1,4 +1,5 @@
 import abc
+from collections.abc import Sequence
 from typing import Any, Iterable, Optional, Type, Union, Protocol, Dict
 from annet.connectors import Connector, get_connector_from_config
 from annet.annlib.netdev.views.hardware import HardwareView
@@ -48,13 +49,21 @@ def resolve_object_ids_by_query(self, query: Any):
     def resolve_fdnds_by_query(self, query: Any):
         pass
 
+    @abc.abstractmethod
+    def resolve_all_fdnds(self) -> list[str]:
+        pass
+
+    @abc.abstractmethod
+    def search_connections(self, device: "Device", neighbor: "Device") -> list[tuple["Interface", "Interface"]]:
+        pass
+
     @abc.abstractmethod
     def make_devices(
         self,
         query: Any,
         preload_neighbors: bool = False,
-        use_mesh: bool = None,
-        preload_extra_fields=False,
+        use_mesh: Optional[bool] = None,
+        preload_extra_fields: bool = False,
         **kwargs,
     ):
         pass
@@ -85,6 +94,17 @@ def is_empty(self) -> bool:
         return False
 
 
+class Interface(Protocol):
+    @property
+    @abc.abstractmethod
+    def name(self) -> str:
+        raise NotImplementedError
+
+    @abc.abstractmethod
+    def add_addr(self, address_mask: str, vrf: Optional[str]) -> None:
+        raise NotImplementedError
+
+
 class Device(Protocol):
     @property
     @abc.abstractmethod
@@ -124,11 +144,30 @@ def hostname(self) -> str:
     def neighbours_ids(self):
         pass
 
+    @property
+    @abc.abstractmethod
+    def neighbours_fqdns(self):
+        pass
+
     @property
     @abc.abstractmethod
     def breed(self) -> str:
         pass
 
+    @abc.abstractmethod
+    def make_lag(self, lag: int, ports: Sequence[str], lag_min_links: Optional[int]) -> Interface:
+        raise NotImplementedError
+
+    @abc.abstractmethod
+    def add_svi(self, svi: int) -> Interface:
+        """Add SVI interface or return existing one"""
+        raise NotImplementedError
+
+    @abc.abstractmethod
+    def add_subif(self, interface: str, subif: int) -> Interface:
+        """Add sub interface or return existing one"""
+        raise NotImplementedError
+
 
 def get_storage() -> tuple[StorageProvider, Dict[str, Any]]:
     connectors = storage_connector.get_all()
diff --git a/annet_generators/example/__init__.py b/annet_generators/example/__init__.py
index a14bfb3..24e4f58 100644
--- a/annet_generators/example/__init__.py
+++ b/annet_generators/example/__init__.py
@@ -7,6 +7,4 @@
 
 
 def get_generators(store: Storage) -> List[BaseGenerator]:
-    return [
-        lldp.Lldp(store),
-    ]
+    return lldp.get_generators(store)
diff --git a/annet_generators/mesh_example/__init__.py b/annet_generators/mesh_example/__init__.py
new file mode 100644
index 0000000..6f872ac
--- /dev/null
+++ b/annet_generators/mesh_example/__init__.py
@@ -0,0 +1,9 @@
+from typing import List
+
+from annet.generators import BaseGenerator
+from annet.storage import Storage
+from . import bgp
+
+
+def get_generators(store: Storage) -> List[BaseGenerator]:
+    return bgp.get_generators(store)
diff --git a/annet_generators/mesh_example/bgp.py b/annet_generators/mesh_example/bgp.py
new file mode 100644
index 0000000..4d85569
--- /dev/null
+++ b/annet_generators/mesh_example/bgp.py
@@ -0,0 +1,43 @@
+from typing import List
+
+from annet.adapters.netbox.common.models import NetboxDevice
+from annet.generators import PartialGenerator, BaseGenerator
+from annet.mesh import MeshExecutor
+from annet.storage import Storage
+from .mesh_logic import registry
+
+
+class Bgp(PartialGenerator):
+    TAGS = ["mgmt", "bgp"]
+
+    def acl_huawei(self, device):
+        return """
+        bgp
+            peer
+        """
+
+    def run_huawei(self, device: NetboxDevice):
+        executor = MeshExecutor(registry, device.storage)
+        res = executor.execute_for(device)
+        yield f"bgp {res.global_options.local_as}"
+        for peer in res.peers:
+            if peer.group_name:
+                yield f"   peer {peer.addr} {peer.group_name}"
+            if peer.remote_as is not None:
+                yield f"   peer {peer.addr} remote-as {peer.remote_as}"
+
+        for group in res.global_options.groups:
+            yield f"   peer {group.name} remote-as {group.remote_as}"
+        for interface in device.interfaces:
+            print(
+                interface.name,
+                interface.lag_min_links,
+                interface.lag.name if interface.lag else None,
+                interface.ip_addresses,
+            )
+
+
+def get_generators(store: Storage) -> List[BaseGenerator]:
+    return [
+        Bgp(store),
+    ]
diff --git a/annet_generators/mesh_example/mesh_logic.py b/annet_generators/mesh_example/mesh_logic.py
new file mode 100644
index 0000000..475100e
--- /dev/null
+++ b/annet_generators/mesh_example/mesh_logic.py
@@ -0,0 +1,28 @@
+from annet.mesh import Right, MeshRulesRegistry, GlobalOptions, MeshSession, DirectPeer
+
+registry = MeshRulesRegistry()
+
+
+@registry.device("{name:.*}")
+def device_handler(global_opts: GlobalOptions):
+    global_opts.local_as = 12345
+    global_opts.groups["GROP_NAME"].remote_as = 11111
+
+
+@registry.direct("{name:.*}", "m9-sgw{x}.{domain:.*}")
+def direct_handler(device: DirectPeer, neighbor: DirectPeer, session: MeshSession):
+    session.asnum = 12345
+    device.addr = "192.168.1.254"
+    neighbor.addr = f"192.168.1.{neighbor.match.x}"
+
+
+@registry.direct("{name:.*}", "m9-sgw{x}.{domain:.*}", Right.x.in_([0, 1]))
+def direct_handler2(device: DirectPeer, neighbor: DirectPeer, session: MeshSession):
+    session.asnum = 12345
+    device.addr = "192.168.1.254/24"
+    device.lag = 1
+    device.lag_links_min = neighbor.match.x
+    device.subif = 100
+    neighbor.families = {"ipv4_unicast"}
+    neighbor.group_name = "GROUP_NAME"
+    neighbor.addr = "192.168.1.200/24"
diff --git a/docs/_templates/gh-pages-redirect.html b/docs/_templates/gh-pages-redirect.html
new file mode 100644
index 0000000..c870a78
--- /dev/null
+++ b/docs/_templates/gh-pages-redirect.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Redirecting to main branch</title>
+    <meta charset="utf-8" />
+    <meta http-equiv="refresh" content="0; url=./main/index.html" />
+    <link rel="canonical" href="https://annetutil.github.io/annet/main/index.html" />
+  </head>
+</html>
diff --git a/docs/_templates/page.html b/docs/_templates/page.html
new file mode 100644
index 0000000..79bf95c
--- /dev/null
+++ b/docs/_templates/page.html
@@ -0,0 +1,17 @@
+{% extends "!page.html" %}
+{% block body %}
+{% if current_version and latest_version and current_version != latest_version %}
+<p>
+  <strong>
+    {% if current_version.is_released %}
+    You're reading an old version of this documentation.
+    If you want up-to-date information, please have a look at <a href="{{ vpathto(latest_version.name) }}">{{latest_version.name}}</a>.
+    {% else %}
+    You're reading the documentation for a development version.
+    For the latest released version, please have a look at <a href="{{ vpathto(latest_version.name) }}">{{latest_version.name}}</a>.
+    {% endif %}
+  </strong>
+</p>
+{% endif %}
+{{ super() }}
+{% endblock %}%
\ No newline at end of file
diff --git a/docs/_templates/versioning.html b/docs/_templates/versioning.html
new file mode 100644
index 0000000..a481b40
--- /dev/null
+++ b/docs/_templates/versioning.html
@@ -0,0 +1,16 @@
+{% if versions %}
+<div class="sidebar-tree">
+    <p class="caption" role="heading">
+        <span class="caption-text">{{ _('Versions') }}</span>
+    </p>
+    <ul>
+        {%- for item in versions %}
+        <li class="toctree-l1">
+            <a class="reference internal" href="{{ item.url }}">
+                {{ item.name }}
+            </a>
+        </li>
+        {%- endfor %}
+    </ul>
+</div>
+{% endif %}
\ No newline at end of file
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..c4283d2
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,69 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# This file only contains a selection of the most common options. For a full
+# list see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+# import os
+# import sys
+# sys.path.insert(0, os.path.abspath('.'))
+
+
+# -- Project information -----------------------------------------------------
+
+project = 'annet'
+copyright = '2024, annetutil'
+author = 'Tishka17'
+master_doc = 'index'
+
+# -- General configuration ---------------------------------------------------
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+    'sphinx.ext.autodoc',
+    "sphinx_multiversion",
+    "sphinx.ext.githubpages",
+]
+autodoc_type_aliases: dict[str, str] = {
+}
+autodoc_typehints = 'description'
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+html_sidebars = {
+    '**': [
+        "sidebar/scroll-start.html",
+        "sidebar/brand.html",
+        "sidebar/search.html",
+        "sidebar/navigation.html",
+        "sidebar/ethical-ads.html",
+        "sidebar/scroll-end.html",
+        'versioning.html',
+    ],
+}
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns: list[str] = []
+
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'furo'
+
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path: list[str] = []
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..65ed227
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,40 @@
+Annet - network configuration utility
+===============================================
+
+The system contains network appliance config generators written in Python
+with optional use of text preprocessors (Jinja2, Mako).
+
+Huawei, Cisco IOS, Cisco NX-OS, Cisco IOS-XR, Juniper, as well as devices configured via separate config files
+(Linux, FreeBSD, Cumulus) are supported.
+
+``annet`` has a number of modes (subcommands):
+
+* ``annet gen`` - generates the entire config for the specified devices or specified parts of it
+* ``annet diff`` - first does gen and then builds diff with current config version
+* ``annet patch`` - first does diff and then generates a list of commands to apply diff on the device
+
+Usage help can be obtained by calling ``annet -h`` or for a specific command, such as ``annet gen -h``.
+
+
+
+.. toctree::
+   :hidden:
+   :caption: Basic usage:
+
+   usage/cli.rst
+   usage/config.rst
+
+
+.. toctree::
+   :hidden:
+   :caption: Extending:
+
+   mesh/index.rst
+
+
+.. toctree::
+    :hidden:
+    :caption: Project Links
+
+    GitHub <https://github.com/annetutil/annet>
+    PyPI <https://pypi.org/project/annet>
\ No newline at end of file
diff --git a/docs/mesh/index.rst b/docs/mesh/index.rst
new file mode 100644
index 0000000..7cb6e76
--- /dev/null
+++ b/docs/mesh/index.rst
@@ -0,0 +1,148 @@
+Mesh and BGP
+==================
+
+With ``annet`` you can configure device BGP session and interfaces based on common rules and connections with other devices.
+
+Configuring mesh
+**********************
+
+To setup configuration you need create an instance of ``MeshRulesRegistry`` and attach handlers of 3 types:
+
+* ``.device`` handlers setup device global configuration
+* ``.direct`` handlers can be used to setup relation between directly connected devices
+* ``.indirect`` handlers can be used to setup configuration related to devices which do not have direct connection
+
+The naming ``.indirect`` reflects the uses cases, it doesn't control to connection of devices: neither they are rechable or connected directly.
+
+.. code-block:: python
+
+    from annet.mesh import (
+        MeshRulesRegistry, GlobalOptions, MeshSession, DirectPeer, IndirectPeer,
+    )
+
+    registry = MeshRulesRegistry()
+    @registry.device("{name:.*}")
+    def foo(global_opts: GlobalOptions):
+        ...
+
+    @registry.direct("{name:.*}.left.example.com", "{name:.*}.right.example.com")
+    def bar(local: DirectPeer, neighbor: DirectPeer, session: MeshSession):
+        ...
+
+    @registry.indirect("{name:.*}.left.example.com", "{name:.*}.other.example.com")
+    def baz(device: IndirectPeer, neighbor: IndirectPeer, session: MeshSession):
+        ...
+
+
+Filtering devices
+------------------------
+
+To select handler you set the device filters. They consist of two parts: name mask and filter expression.
+
+**name mask** is a string which describes device FQDN, optionally containing placeholders for captured variables.
+Place holders can be of two forms:
+
+* ``{var}`` means a variable containing any non-negative integer number. You will be abel to access it in your handler as an integer attribute.
+* ``{var:regex}`` means a string matching regular expression. You will be abel to access it in your handler as an string attribute.
+
+Additionally, you can set a **filter expression** based on captured variables using *magic* filters.
+Use ``Match`` for device handler, and ``Left`` and ``Right`` for direct/indirect handlers.
+
+For example, here
+
+* ``foo`` function will be applied to any device with number from 0 to 100 after ``host-`` prefix in subdomain.
+* ``bar`` will be applied to the pair of hosts with same top level domain and where the left number is less than right one
+
+.. code-block:: python
+
+    from annet.mesh import Match, Left, Right
+
+    @registry.device("host-{num}.{domain:.*}", Match.num<100)
+    def foo(global_opts: GlobalOptions):
+        ...
+
+    @registry.direct(
+        "host-{num}.{domain:.*}", "host-{num}.{domain:.*}",
+        Left.num < Right.num,
+        Left.domain == Right.domain,
+    )
+    def bar(local: DirectPeer, neighbor: DirectPeer, session: MeshSession):
+        ...
+
+
+If you need more complex filters or check not only the FQDN, you should do the check inside handler and return from it without any modifications.
+
+Short name filters
+--------------------
+
+Devices are normally filtered based on FQDN, but can set registry to use only short name (subdomain). This is done setting ``match_short_name`` in the registry constructor.
+Note, that is only applied for specific registry and does not affect others, even included.
+
+.. code-block:: python
+
+    registry = MeshRulesRegistry(match_short_name=True)
+
+Accessing captured variables
+------------------------------
+
+Variables captured from hostname are available via ``.match`` attribute of ``GlobalOptions``, ``DirectPeer`` and ``IndirectPeer`` objects.
+
+.. code-block:: python
+
+    @registry.device("host-{num}.{domain:.*}")
+    def foo(global_opts: GlobalOptions):
+        print(global_opts.match.num)
+
+Accessing device data
+------------------------------
+
+Device instance is accessible via ``.device`` attribute of ``GlobalOptions``, ``DirectPeer`` and ``IndirectPeer`` objects.
+``DirectPeer`` additionally has ``ports`` field with names of interfaces used for a connection between devices
+(the order is preserved for both sides)
+
+.. code-block:: python
+
+    @registry.direct("host-{num}.{domain:.*}", "host-{num}.{domain:.*}")
+    def bar(local: DirectPeer, neighbor: DirectPeer, session: MeshSession):
+        print(local.device.fqdn)
+        print(local.device.ports)
+
+
+
+Filling mesh data
+------------------------
+
+Each handler can fill predefined attributes in ``GlobalOptions``, ``DirectPeer``, ``IndirectPeer`` and ``Session`` objects,
+this includes peer groups, vrf, interfaces used for BGP session and various options.
+
+Configuration, received from different handlers will be merged together.
+You cannot set different values for the same option in different handlers, but complex objects are merged recursively.
+``Session`` object contains data which is applied to both peers.
+
+Minimum of data required to be filled is ``DirectPeer`` and ``IndirectPeer``
+
+* ``addr``
+* ``remote_as``
+* ``families``
+
+
+Bgp session is expected to be set on single interface and you can choose it from these options:
+
+* *(default)* the single physical interface through which the connection is made (with validation if it is the only one)
+* sub-interface in case it is the one interface available
+* lag, containing all interfaces holding the connection between devices
+* subif for the lag
+* svi
+
+The selection is done using ``lag``, ``svi`` or ``subif`` attributes correspondingly.
+
+
+Accessing mesh data from generators
+****************************************
+
+Mesh is not processed automatically, to use it from generator you need:
+
+* Import your ``MeshRulesRegistry`` instance. You can use ``registry.include`` to combine rules from multiple registries.
+* Create executor: ``MeshExecutor(registry, device.storage)``
+* Run it against the device ``res = executor.execute_for(device)``. Additionally to the result, the device can be modified to store additional interfaces
+* Use the result or patched device to generate BGP configuration.
\ No newline at end of file
diff --git a/docs/usage/cli.rst b/docs/usage/cli.rst
new file mode 100644
index 0000000..4fc2b84
--- /dev/null
+++ b/docs/usage/cli.rst
@@ -0,0 +1,92 @@
+CLI Usage
+================
+
+annet gen
+******************
+
+The annet_generators directory contains many files called generators.
+A generator takes information about the switch as input and returns the configuration.
+The part of the config that the generator is responsible for is specified in the generator's acl function. If a generator returns a configuration that does not fall under acl, an exception will be thrown.
+
+Example generator:
+
+.. code-block:: python
+
+    from annet.generators import PartialGenerator
+
+    class Mtu(PartialGenerator):
+        TAGS = ["mtu"]
+        def acl_cisco(self, _):
+            return "system mtu jumbo"
+
+        def run_cisco(self, device):
+            yield "system mtu jumbo %d" % 9000
+
+
+
+And an example of calling annet:
+
+.. code-block:: bash
+
+    annet gen -g mtu sw6-i1
+    # -------------------- sw6-i1.cfg --------------------
+    system mtu jumbo 9000
+
+
+Method ``acl_cisco`` defines scope of the generator, which commands and block it controls.
+The option ``-g mtu`` means that only generators with the mtu element in the TAGS variable should be called.
+If no tag is specified, all generators will be executed.
+
+
+annet diff
+******************
+
+If we were configuring the switch from scratch, these options would be enough, but in our reality we need to be able not only to generate the desired configuration, but also to be able to bring the current configuration to the desired one.
+To do this, you need to be able to delete an outdated configuration and correctly add a new one. The **diff** module, which implements some tricky logic, is responsible for this work.
+This logic is defined in the rulebook/texts/VENDOR folder.
+
+Example diff:
+
+.. code-block:: diff
+
+    # -------------------- sw1-i38.cfg --------------------
+      acl number 2610
+    - rule 40 permit source 10.11.170.150 0
+    + rule 12 permit source 10.11.133.81 0
+
+
+annet patch
+******************
+
+Next, you need to create a list of commands from the resulting diff. The **patch** module is responsible for this.
+It receives the diff, runs the logic specified in rulebook/texts/VENDOR and returns the list of commands.
+Let's take the above diff. It says to remove the command ``rule 40 permit source 10.11.170.150 0`` and
+add ``rule 12 permit source 10.11.133.81 0``.
+Basic command delete logic for huawei is adding undo to the command.
+So the undo command will look like this: ``undo rule 40 permit source 10.11.170.150 0``,
+but this is an invalid command. In case of canceling acl rules, you need to execute ``undo rule N``.
+So you need to write the undo logic for the ``rule`` command in the ``acl`` block.
+Here is the part of rulebook/texts/huawei.rul responsible for this:
+
+.. code-block::
+
+    acl name *
+        rule * %logic=huawei.misc.undo_redo
+
+The asterisk here means that the key argument of the undo_redo function will contain the first word after rule,
+namely the rule number.
+
+Here, the ``undo_redo`` function from the file in rulebook/huawei/misc.py is used to generate the command to remove rules in acl.
+
+.. code-block:: python
+
+    def undo_redo(rule, key, diff, **_):
+        ...
+
+Now calling ``annet patch -g snmp sw1-i38`` returns the correct set of commands.
+.. code-block::
+
+    acl number 2610
+      undo rule 40
+      rule 12 permit source 10.11.133.81 0
+      quit
diff --git a/docs/usage/config.rst b/docs/usage/config.rst
new file mode 100644
index 0000000..8d0e5f1
--- /dev/null
+++ b/docs/usage/config.rst
@@ -0,0 +1,75 @@
+Configuration
+==========================
+
+The path to the configuration file is searched in following order:
+- ``ANN_CONTEXT_CONFIG_PATH`` env.
+- ``~/.annet/context.yml``.
+- ``annet/configs/context.yml``.
+
+Config example:
+
+.. code-block:: yaml
+
+    connection:
+      default:
+        login: ~
+        passwords: ~
+
+    generators:
+      default:
+        - my_annet_generators.example
+
+    storage:
+      default:
+        adapter: annet.adapters.file.provider
+        params:
+          path: /path/to/file
+
+    context:
+      default:
+        connection: default
+        generators: default
+        storage: default
+
+    selected_context: default
+
+
+Environment variable ``ANN_SELECTED_CONTEXT`` can be used to override ``selected_context`` parameter.
+
+Storages
+************************
+
+Storages provide information about devices like FQDN, interface and so on.
+
+Netbox storage
+----------------------
+
+Provide ``NETBOX_URL`` and ``NETBOX_TOKEN`` environment variable to setup data source.
+
+.. code-block:: shell
+
+    export NETBOX_URL="https://demo.netbox.dev"
+    export NETBOX_TOKEN="1234567890abcdef01234567890abcdef0123456"
+
+File storage
+----------------------
+
+.. code-block:: yaml
+
+    storage:
+      default:
+        adapter: annet.adapters.file.provider
+        params:
+          path: /path/to/file
+
+
+cat /path/to/file:
+
+.. code-block:: yaml
+
+    devices:
+      - fqdn: myhost.yndx.net
+        vendor: mikrotik
+        interfaces:
+          - name: eth0
+            description: test
diff --git a/requirements-doc.txt b/requirements-doc.txt
new file mode 100644
index 0000000..b4b8edd
--- /dev/null
+++ b/requirements-doc.txt
@@ -0,0 +1,4 @@
+sphinx==8.1.*
+sphinx-multiversion==0.2.4
+sphinx-autodocgen==1.3
+furo==2024.8.*


Test patch:
diff --git a/tests/annet/test_mesh/__init__.py b/tests/annet/test_mesh/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/annet/test_mesh/fakes.py b/tests/annet/test_mesh/fakes.py
new file mode 100644
index 0000000..2435a95
--- /dev/null
+++ b/tests/annet/test_mesh/fakes.py
@@ -0,0 +1,145 @@
+from typing import Any, Optional, Sequence
+
+from annet.mesh.executor import Device
+from annet.storage import Storage, Interface
+
+
+class FakeInterface(Interface):
+    def __init__(self, name: str, neighbor_fqdn: Optional[str], neighbor_port: Optional[str]):
+        self._name = name
+        self.addrs: list[tuple[str, Optional[str]]] = []
+        self.neighbor_fqdn = neighbor_fqdn
+        self.neighbor_port = neighbor_port
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    def add_addr(self, address_mask: str, vrf: Optional[str]) -> None:
+        self.addrs.append((address_mask, vrf))
+
+
+class FakeDevice(Device):
+    def __init__(self, name: str, interfaces: list[FakeInterface]) -> None:
+        self._name = name
+        self.interfaces = interfaces
+        self._storage = None
+
+    @property
+    def storage(self) -> Storage:
+        return self._storage
+
+    @storage.setter
+    def storage(self, storage: Storage) -> None:
+        self._storage = storage
+
+    @property
+    def id(self):
+        return self._name
+
+    @property
+    def fqdn(self):
+        return self._name
+
+    @property
+    def hostname(self):
+        return self._name
+
+    def __hash__(self):
+        return hash(self._name)
+
+    def is_pc(self):
+        return False
+
+    @property
+    def hw(self):
+        pass
+
+    @property
+    def neighbours_ids(self) -> list["str"]:
+        return [n.neighbor_fqdn for n in self.interfaces if n.neighbor_fqdn]
+
+    @property
+    def neighbours_fqdns(self) -> list["str"]:
+        return [n.neighbor_fqdn for n in self.interfaces if n.neighbor_fqdn]
+
+    @property
+    def breed(self):
+        pass
+
+    def make_lag(self, lag: int, ports: Sequence[str], lag_min_links: Optional[int]) -> Interface:
+        self.interfaces.append(FakeInterface(
+            name=f"Trunk{lag}",
+            neighbor_port=None,
+            neighbor_fqdn=None,
+        ))
+        return self.interfaces[-1]
+
+    def add_svi(self, svi: int) -> Interface:
+        self.interfaces.append(FakeInterface(
+            name=f"Vlan{svi}",
+            neighbor_port=None,
+            neighbor_fqdn=None,
+        ))
+        return self.interfaces[-1]
+
+    def add_subif(self, interface: str, subif: int) -> Interface:
+        self.interfaces.append(FakeInterface(
+            name=f"{interface}.{subif}",
+            neighbor_port=None,
+            neighbor_fqdn=None,
+        ))
+        return self.interfaces[-1]
+
+
+class FakeStorage(Storage):
+    def __init__(self):
+        self.devices: list[FakeDevice] = []
+
+    def add_device(self, device: FakeDevice):
+        self.devices.append(device)
+
+    def __enter__(self):
+        pass
+
+    def __exit__(self, _, __, ___):
+        pass
+
+    def resolve_object_ids_by_query(self, query: Any):
+        return [
+            d.id for d in self.devices
+            if d.fqdn in query
+        ]
+
+    def resolve_all_fdnds(self) -> list[str]:
+        return [d.fqdn for d in self.devices]
+
+    def resolve_fdnds_by_query(self, query: Any):
+        return [
+            d.fqdn for d in self.devices
+            if d.fqdn in query
+        ]
+
+    def make_devices(self, query: Any, preload_neighbors: bool = False, use_mesh: Optional[bool] = None,
+                     preload_extra_fields=False, **kwargs):
+        return [
+            d for d in self.devices
+            if d.fqdn in query
+        ]
+
+    def get_device(self, obj_id, preload_neighbors=False, use_mesh=None, **kwargs) -> "Device":
+        return next(d for d in self.devices if d.id == obj_id)
+
+    def flush_perf(self):
+        pass
+
+    def search_connections(
+        self, device: "FakeDevice", neighbor: "FakeDevice",
+    ) -> list[tuple["FakeInterface", "FakeInterface"]]:
+        res = []
+        for local_port in device.interfaces:
+            if local_port.neighbor_fqdn == neighbor.fqdn:
+                for remote_port in neighbor.interfaces:
+                    if remote_port.name == local_port.neighbor_port:
+                        res.append((local_port, remote_port))
+        return res
\ No newline at end of file
diff --git a/tests/annet/test_mesh/test_basemodel.py b/tests/annet/test_mesh/test_basemodel.py
new file mode 100644
index 0000000..b637feb
--- /dev/null
+++ b/tests/annet/test_mesh/test_basemodel.py
@@ -0,0 +1,69 @@
+from typing import Annotated
+
+import pytest
+
+from annet.mesh.basemodel import (
+    UseFirst, UseLast, Special, Forbid, ForbidChange, Concat, DictMerge, BaseMeshModel, MergeForbiddenError, merge,
+    Merge,
+)
+
+
+@pytest.mark.parametrize(
+    ["first", "second", "merger", "expected"],
+    [
+        ("a", "b", UseFirst(), "a"),
+        (Special.NOT_SET, "b", UseFirst(), "b"),
+        ("a", "b", UseLast(), "b"),
+        ("a", Special.NOT_SET, UseLast(), "a"),
+        ("a", Special.NOT_SET, Forbid(), "a"),
+        (Special.NOT_SET, "b", Forbid(), "b"),
+        (Special.NOT_SET, "b", ForbidChange(), "b"),
+        ("a", Special.NOT_SET, ForbidChange(), "a"),
+        ("a", "a", ForbidChange(), "a"),
+        ("a", "b", Concat(), "ab"),
+        ({"x": 1}, {"y": 2}, DictMerge(), {"x": 1, "y": 2}),
+        ({"x": "a"}, {"x": "b"}, DictMerge(Concat()), {"x": "ab"}),
+    ]
+)
+def test_merger_class(first, second, merger, expected):
+    assert expected == merger("name", first, second)
+
+
+@pytest.mark.parametrize(
+    ["first", "second", "merger"],
+    [
+        ("a", "b", Forbid()),
+        ("a", "b", ForbidChange()),
+    ]
+)
+def test_merger_class_raise(first, second, merger):
+    with pytest.raises(MergeForbiddenError):
+        merger("name", first, second)
+
+
+def test_merge_model():
+    class A(BaseMeshModel):
+        x: Annotated[int, UseLast()]
+        y: Annotated[int, UseFirst()]
+
+        def __eq__(self, other):
+            return vars(self) == vars(other)
+
+    assert merge(A(x=1), A(y=2)) == A(x=1, y=2)
+    assert merge(A(x=1, y=1), A(x=2, y=2)) == A(x=2, y=1)
+
+    merger = Merge()
+    assert merger("x", A(x=1, y=1), A(x=2, y=2)) == A(x=2, y=1)
+
+
+def test_merge_model_default():
+    class A(BaseMeshModel):
+        x: int
+
+        def __eq__(self, other):
+            return vars(self) == vars(other)
+
+    assert merge(A(x=1), A()) == A(x=1)
+    assert merge(A(x=1), A(x=1)) == A(x=1)
+    with pytest.raises(MergeForbiddenError):
+        merge(A(x=1), A(x=2))
diff --git a/tests/annet/test_mesh/test_executor.py b/tests/annet/test_mesh/test_executor.py
new file mode 100644
index 0000000..99f7685
--- /dev/null
+++ b/tests/annet/test_mesh/test_executor.py
@@ -0,0 +1,145 @@
+import pytest
+
+from annet.mesh import MeshExecutor, MeshRulesRegistry, GlobalOptions, DirectPeer, MeshSession, IndirectPeer
+from .fakes import FakeStorage, FakeDevice, FakeInterface
+
+VRF = "testvrf"
+GROUP = "test_group"
+
+
+def on_device_x(device: GlobalOptions):
+    device.vrf[VRF].groups[GROUP].mtu = 1499
+    print(device.match.x)
+
+
+def on_direct(local: DirectPeer, neighbor: DirectPeer, session: MeshSession):
+    local.addr = "192.168.1.254"
+    neighbor.addr = "192.168.1.1"
+    local.mtu = 1501
+    neighbor.mtu = 1502
+    session.asnum = 12345
+    session.families = {"ipv4_unicast"}
+
+def on_direct_alt(local: DirectPeer, neighbor: DirectPeer, session: MeshSession):
+    local.addr = "192.168.1.254"
+    neighbor.addr = "192.168.1.2"
+    local.mtu = 1501
+    neighbor.mtu = 1502
+    session.asnum = 12345
+    session.families = {"ipv4_labeled"}
+
+
+def on_indirect(local: IndirectPeer, neighbor: IndirectPeer, session: MeshSession):
+    local.addr = "192.168.1.254"
+    neighbor.addr = "192.168.1.10"
+    local.mtu = 1505
+    neighbor.mtu = 1506
+    session.asnum = 12340
+    session.families = {"ipv6_unicast"}
+
+
+def on_indirect_alt(local: IndirectPeer, neighbor: IndirectPeer, session: MeshSession):
+    local.addr = "192.168.1.254"
+    neighbor.addr = "192.168.1.11"
+    local.mtu = 1506
+    neighbor.mtu = 1507
+    session.asnum = 12340
+    session.families = {"ipv6_unicast"}
+
+
+@pytest.fixture
+def registry():
+    r = MeshRulesRegistry()
+    r.device("{x:.*}")(on_device_x)
+    r.direct("dev{num}.example.com", "dev_{x:.*}")(on_direct)
+    r.direct("dev{num}.example.com", "dev_{x:.*}")(on_direct_alt)
+    r.indirect("dev{num}.example.com", "dev{num}.remote.example.com")(on_indirect)
+    r.indirect("dev{num}.example.com", "dev{num}.remote.example.com")(on_indirect_alt)
+    return r
+
+
+DEV1 = "dev1.example.com"
+DEV2 = "dev2.remote.example.com"
+DEV_NEIGHBOR = "dev_neighbor.example.com"
+
+
+@pytest.fixture()
+def device1():
+    return FakeDevice(DEV1, [
+        FakeInterface("if20", None, None),
+    ])
+
+
+@pytest.fixture()
+def device2():
+    return FakeDevice(DEV2, [
+        FakeInterface("if20", None, None),
+    ])
+
+
+@pytest.fixture()
+def device_neighbor(device1):
+    device1.interfaces.append(FakeInterface(
+        name="if1",
+        neighbor_fqdn=DEV_NEIGHBOR,
+        neighbor_port="if10"
+    ))
+    return FakeDevice(DEV_NEIGHBOR, [
+        FakeInterface(
+            name="if10",
+            neighbor_fqdn=DEV1,
+            neighbor_port="if1"
+        )
+    ])
+
+
+@pytest.fixture
+def storage(device1, device2, device_neighbor):
+    s = FakeStorage()
+    s.add_device(device1)
+    s.add_device(device2)
+    s.add_device(device_neighbor)
+    return s
+
+
+def test_storage(registry, storage, device1):
+    r = MeshExecutor(registry, storage)
+    res = r.execute_for(device1)
+
+    assert res.global_options.groups == []
+    assert res.global_options.vrf.keys() == {VRF}
+    vrf = res.global_options.vrf[VRF]
+    assert vrf.vrf_name == VRF
+    assert vrf.static_label is None
+    assert len(vrf.groups) == 1
+    assert vrf.groups[0].mtu == 1499
+    assert vrf.groups[0].name == GROUP
+
+    peer_direct, peer_direct_alt, peer_indirect, peer_indirect_alt = res.peers
+    assert peer_direct.addr == "192.168.1.1"
+    assert peer_direct.options.mtu == 1501
+    assert peer_direct.families == {"ipv4_unicast"}
+    assert peer_direct.remote_as == 12345
+    assert peer_direct.description == ""
+    assert peer_direct.interface == "if1"
+
+    assert peer_direct_alt.addr == "192.168.1.2"
+    assert peer_direct_alt.options.mtu == 1501
+    assert peer_direct_alt.families == {"ipv4_labeled"}
+    assert peer_direct_alt.remote_as == 12345
+    assert peer_direct_alt.description == ""
+    assert peer_direct_alt.interface == "if1"
+
+    assert peer_indirect.addr == "192.168.1.10"
+    assert peer_indirect.options.mtu == 1505
+    assert peer_indirect.families == {"ipv6_unicast"}
+    assert peer_indirect.remote_as == 12340
+    assert peer_indirect.description == ""
+    assert peer_indirect.interface is None
+
+    assert peer_indirect_alt.addr == "192.168.1.11"
+    assert peer_indirect_alt.options.mtu == 1506
+    assert peer_indirect_alt.families == {"ipv6_unicast"}
+    assert peer_indirect_alt.remote_as == 12340
+    assert peer_indirect_alt.description == ""
+    assert peer_indirect_alt.interface is None
diff --git a/tests/annet/test_mesh/test_match_args.py b/tests/annet/test_mesh/test_match_args.py
new file mode 100644
index 0000000..df597de
--- /dev/null
+++ b/tests/annet/test_mesh/test_match_args.py
@@ -0,0 +1,94 @@
+from typing import Any, Optional
+
+from annet.mesh.match_args import MatchExpr, match_safe, PeerNameTemplate, SingleMatcher, Left, Match, Right, \
+    PairMatcher
+
+F = MatchExpr()
+
+
+class A:
+    one = 1
+    two = 2
+
+
+def match(expr: MatchExpr, value) -> bool:
+    return match_safe((expr,), value)
+
+
+def test_match_expr():
+    assert match(F == 1, 1)
+    assert not match(F == 1, "1")
+    assert not match(F == 2, 1)
+    assert match(F >= 1, 1)
+    assert match(F < 2, 1)
+    assert not match(F > 10, 1)
+    assert match(F != 10, 1)
+
+    assert match(F.one == 1, A())
+    assert not match(F.two == 1, A())
+    assert not match(F.invalid == 1, A())
+
+    assert match(F[0] == 10, [10])
+    assert not match(F[0] == 10, [11])
+    assert not match(F[100] == 10, [10])
+    assert match(F["a"] == 10, {"a": 10})
+    assert not match(F["invalid"] == 10, {"a": 1})
+
+    assert match((F.one == 1) & (F.two == 2), A())
+    assert match((F.one == 2) | (F.two == 2), A())
+    assert not match((F.one == 2) & (F.two == 2), A())
+
+    assert match(F.in_([1, 2]), 2)
+    assert not match(F.in_([1, 2]), 100)
+
+    assert match(F.cast_(str) == "1", 1)
+    assert match(F.cast_(int) == 1, "1")
+
+    assert match(F[0]["x"] > 1, [{"x": 2}])
+    assert not match(F[1]["x"] > 1, [{"x": 2}])
+    assert match(F.one < F.two, A())
+    assert not match(F.one > F.two, A())
+
+
+def match_name(template: str, name: str) -> Optional[dict[str, Any]]:
+    res = PeerNameTemplate(template).match(name)
+    if res is None:
+        return None
+    return dict(res)
+
+
+def test_peer_name_template_type_cast():
+    assert match_name("{x}", "12") == {"x": 12}
+    assert match_name("{x:.*}", "12") == {"x": "12"}
+    assert match_name(r"{x:\d+}", "12") == {"x": "12"}
+
+
+def test_peer_name_template():
+    assert match_name("{x}", "12") == {"x": 12}
+    assert match_name("{x}.example.com", "12.example.com") == {"x": 12}
+    assert match_name("{x}", "12.example.com") is None
+    assert match_name("{x}-{y}", "12-2") == {"x": 12, "y": 2}
+
+    assert match_name("{x:.*}", "12") == {"x": "12"}
+    assert match_name(".{x:(a|b)}.", ".a.") == {"x": "a"}
+    assert match_name(".{x:(a|b)}.", ".x.") is None
+    assert match_name(".{x:(a|b)}.", ".aa.") is None
+    assert match_name(".{x:(a|b)+}.", ".aa.") == {"x": "aa"}
+
+
+def test_single_matcher():
+    matcher = SingleMatcher("{x}.example.com", [Match.x < 5])
+    assert matcher.match_one("1.example.com")
+    assert matcher.match_one("4.example.com")
+    assert not matcher.match_one("6.example.com")
+    assert not matcher.match_one("xxx.example.com")
+    assert not matcher.match_one("invalid")
+
+
+def test_pair_matcher():
+    matcher = PairMatcher("{x}.example.com", "{x}.example.com", [Left.x < Right.x])
+    assert matcher.match_pair("1.example.com", "2.example.com")
+    assert not matcher.match_pair("100.example.com", "2.example.com")
+    assert matcher.match_pair("100.example.com", "200.example.com")
+    assert not matcher.match_pair("1.example.com", "xxx.example.com")
+    assert not matcher.match_pair("xxx.example.com", "2.example.com")
diff --git a/tests/annet/test_mesh/test_registry.py b/tests/annet/test_mesh/test_registry.py
new file mode 100644
index 0000000..a597efe
--- /dev/null
+++ b/tests/annet/test_mesh/test_registry.py
@@ -0,0 +1,109 @@
+import pytest
+
+from annet.mesh.match_args import Left, Right
+from annet.mesh.registry import MeshRulesRegistry
+
+
+def foo(*args, **kwargs) -> None:
+    pass
+
+
+def bar(*args, **kwargs) -> None:
+    pass
+
+
+def baz(*args, **kwargs) -> None:
+    pass
+
+
+@pytest.mark.parametrize("short", [True, False])
+def test_global(short):
+    registry = MeshRulesRegistry(short)
+    registry.device("left1-{x}")(foo)
+    registry.device("left1-{x}", Left.x > 1)(bar)
+    registry.device("left2-{x}")(baz)
+
+    found = registry.lookup_global("left1-1")
+    assert len(found) == 1
+    assert found[0].handler is foo
+    assert found[0].match.x == 1
+
+    short_found = registry.lookup_global("left1-1.example.com")
+    if short:
+        assert short_found == found
+    else:
+        assert short_found == []
+
+
+@pytest.mark.parametrize("short", [True, False])
+def test_direct(short):
+    registry = MeshRulesRegistry(short)
+    registry.direct("left1-{x}", "right1-{x}")(foo)
+    registry.direct("left1-{x}", "right1-{x}", Left.x > Right.x)(bar)
+    registry.direct("left2-{x}", "right1-{x}")(baz)
+
+    found = registry.lookup_direct(
+        "left1-1",
+        ["right1-2", "right2"],
+    )
+    assert len(found) == 1
+    assert found[0].handler is foo
+    assert found[0].match_left.x == 1
+    assert found[0].match_right.x == 2
+
+    short_found = registry.lookup_direct(
+        "left1-1.example.com",
+        ["right1-2.example.com", "right2-2.example.com"],
+    )
+    if short:
+        assert short_found == found
+    else:
+        assert short_found == []
+
+
+@pytest.mark.parametrize("short", [True, False])
+def test_indirect(short):
+    registry = MeshRulesRegistry(short)
+    registry.indirect("left1-{x}", "right1-{x}")(foo)
+    registry.indirect("left1-{x}", "right1-{x}", Left.x > Right.x)(bar)
+    registry.indirect("left2-{x}", "right1-{x}")(baz)
+
+    found = registry.lookup_indirect(
+        "left1-1",
+        ["right1-2", "right2"],
+    )
+    assert len(found) == 1
+    assert found[0].handler is foo
+    assert found[0].match_left.x == 1
+    assert found[0].match_right.x == 2
+
+    short_found = registry.lookup_indirect(
+        "left1-1.example.com",
+        ["right1-2.example.com", "right2-2.example.com"],
+    )
+    if short:
+        assert short_found == found
+    else:
+        assert short_found == []
+
+
+def test_include():
+    registry = MeshRulesRegistry()
+    registry.device("left1-{x}")(foo)
+    registry.direct("left1-{x}", "right1-{x}")(foo)
+    registry.indirect("left1-{x}", "right1-{x}")(foo)
+    main_registry = MeshRulesRegistry()
+    main_registry.include(registry)
+
+    found = registry.lookup_global("left1-1")
+    assert len(found) == 1
+    found = registry.lookup_indirect(
+        "left1-1",
+        ["right1-2", "right2"],
+    )
+    assert len(found) == 1
+    found = registry.lookup_indirect(
+        "left1-1",
+        ["right1-2", "right2"],
+    )
+    assert len(found) == 1