Skip to content

Commit

Permalink
[NDM] [Cisco ACI] Support submitting topology metadata (#18675)
Browse files Browse the repository at this point in the history
* First pass at sending topology link metadata

Fix pydantic model

* Add changelog :-)

* Clean up + add some test cases

* Amend getting the local interface DD ID for topology

* Add local resolution, utilize interface raw ID

* Add integration field for TopologyLinkMetadata

* Fix interface raw ID type, local interface ID

* Reference to local interface ID

* Update changelog to be explicit about LLDP support
  • Loading branch information
zoedt authored Dec 6, 2024
1 parent da208a8 commit 693145c
Show file tree
Hide file tree
Showing 12 changed files with 401 additions and 5 deletions.
2 changes: 1 addition & 1 deletion cisco_aci/assets/configuration/spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ files:
example: default
- name: send_ndm_metadata
description: |
Set to `true` to enable Network Device Monitoring metadata (for devices and interfaces) to be sent.
Set to `true` to enable Network Device Monitoring metadata (for devices, interfaces, topology) to be sent.
value:
type: boolean
example: False
Expand Down
1 change: 1 addition & 0 deletions cisco_aci/changelog.d/18675.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[NDM] [Cisco ACI] Support submitting topology metadata (utilizing LLDP neighbor information)
10 changes: 10 additions & 0 deletions cisco_aci/datadog_checks/cisco_aci/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,16 @@ def get_eth_list_and_stats(self, pod, node):
response = self.make_request(path)
return self._parse_response(response)

def get_lldp_adj_eps(self):
path = '/api/node/class/lldpAdjEp.json'
response = self.make_request(path)
return self._parse_response(response)

def get_cdp_adj_eps(self):
path = '/api/node/class/cdpAdjEp.json'
response = self.make_request(path)
return self._parse_response(response)

def get_eqpt_capacity(self, eqpt):
base_path = '/api/class/eqptcapacityEntity.json'
base_query = 'query-target=self&rsp-subtree-include=stats&rsp-subtree-class='
Expand Down
2 changes: 1 addition & 1 deletion cisco_aci/datadog_checks/cisco_aci/data/conf.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ instances:
# namespace: default

## @param send_ndm_metadata - boolean - optional - default: false
## Set to `true` to enable Network Device Monitoring metadata (for devices and interfaces) to be sent.
## Set to `true` to enable Network Device Monitoring metadata (for devices, interfaces, topology) to be sent.
#
# send_ndm_metadata: false

Expand Down
8 changes: 7 additions & 1 deletion cisco_aci/datadog_checks/cisco_aci/fabric.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,14 @@ def collect(self):
pods = self.submit_pod_health(fabric_pods)
devices, interfaces = self.submit_nodes_health_and_metadata(fabric_nodes, pods)
if self.ndm_enabled():
# get topology link metadata
lldp_adj_eps = self.api.get_lldp_adj_eps()
cdp_adj_eps = self.api.get_cdp_adj_eps()
device_map = ndm.get_device_ip_mapping(devices)
links = ndm.create_topology_link_metadata(lldp_adj_eps, cdp_adj_eps, device_map, self.namespace)

collect_timestamp = int(time.time())
batches = ndm.batch_payloads(self.namespace, devices, interfaces, collect_timestamp)
batches = ndm.batch_payloads(self.namespace, devices, interfaces, links, collect_timestamp)
for batch in batches:
self.event_platform_event(json.dumps(batch.model_dump(exclude_none=True)), "network-devices-metadata")

Expand Down
18 changes: 18 additions & 0 deletions cisco_aci/datadog_checks/cisco_aci/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
EPG_REGEX = re.compile('/epg-([^/]+)/')
IP_REGEX = re.compile('/ip-([^/]+)/')
NODE_REGEX = re.compile('node-([0-9]+)')
ETH_REGEX = re.compile(r'\[([^]]*)\]')


def parse_capacity_tags(dn):
Expand Down Expand Up @@ -85,6 +86,23 @@ def get_node_from_dn(dn):
return _get_value_from_dn(NODE_REGEX, dn)


def get_eth_id_from_dn(dn):
"""
This parses the interface ID (eth) from a dn designator. They look like this:
topology/pod-1/node-101/sys/lldp/inst/if-[eth1/49]/adj-1
"""
return _get_value_from_dn(ETH_REGEX, dn)


def get_index_from_eth_id(eth_id):
"""
This parses the interface index (eth) from an interface's ID. They look like this:
eth1/49
"""
split = re.split('eth|/', eth_id)
return int(split[-1])


def _get_value_from_dn(regex, dn):
if not dn:
return None
Expand Down
116 changes: 115 additions & 1 deletion cisco_aci/datadog_checks/cisco_aci/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator

from . import helpers

"""
Cisco ACI Response Models
"""
Expand Down Expand Up @@ -77,6 +79,77 @@ def ethpm_phys_if(self) -> Optional[EthpmPhysIf]:
return None


class LldpAdjAttributes(BaseModel):
chassis_id_t: Optional[str] = Field(default=None, alias="chassisIdT")
chassis_id_v: Optional[str] = Field(default=None, alias="chassisIdV")
dn: Optional[str] = None
mgmt_ip: Optional[str] = Field(default=None, alias="mgmtIp")
mgmt_port_mac: Optional[str] = Field(default=None, alias="mgmtPortMac")
port_desc: Optional[str] = Field(default=None, alias="portDesc")
port_id_t: Optional[str] = Field(default=None, alias="portIdT")
port_id_v: Optional[str] = Field(default=None, alias="portIdV")
sys_desc: Optional[str] = Field(default=None, alias="sysDesc")
sys_name: Optional[str] = Field(default=None, alias="sysName")

@computed_field
@property
def ndm_remote_interface_type(self) -> str:
# map the Cisco ACI port subtype to match what NDM (writer) expects
port_subtype_mapping = {
"if-alias": "interface_alias",
"port-name": "interface_name",
"mac": "mac_address",
"nw-addr": "network_address",
"if-name": "interface_name",
"agent-ckt-id": "agent_circuit_id",
"local": "local",
}
if self.port_id_t:
return port_subtype_mapping.get(self.port_id_t, "unknown")
return "unknown"

@computed_field
@property
def local_device_dn(self) -> str:
# example: topology/pod-1/node-101/sys/lldp/inst/if-[eth1/49]/adj-1
return helpers.get_hostname_from_dn(self.dn)

@computed_field
@property
def local_port_id(self) -> str:
# example: topology/pod-1/paths-201/path-ep-[eth1/1]
# use regex to extract port alias from square brackets - ex: eth1/1
return helpers.get_eth_id_from_dn(self.dn)

@computed_field
@property
def local_port_index(self) -> int:
return helpers.get_index_from_eth_id(self.local_port_id)

@computed_field
@property
def remote_device_dn(self) -> str:
# example: topology/pod-1/paths-201/path-ep-[eth1/1]
# use regex to extract the pod/node - ex: pod-1-node-201
return helpers.get_hostname_from_dn(self.sys_desc)

@computed_field
@property
def remote_port_id(self) -> str:
# example: topology/pod-1/paths-201/path-ep-[eth1/1]
# use regex to extract port alias from square brackets - ex: eth1/1
return helpers.get_eth_id_from_dn(self.port_desc)

@computed_field
@property
def remote_port_index(self) -> int:
return helpers.get_index_from_eth_id(self.remote_port_id)


class LldpAdjEp(BaseModel):
attributes: LldpAdjAttributes


"""
NDM Models
"""
Expand All @@ -96,6 +169,9 @@ class DeviceMetadata(BaseModel):
device_type: Optional[str] = Field(default=None)
integration: Optional[str] = Field(default='cisco-aci')

# non-exported fields
pod_node_id: Optional[str] = Field(default=None, exclude=True)

@computed_field
@property
def status(self) -> int:
Expand Down Expand Up @@ -136,7 +212,7 @@ class InterfaceMetadata(BaseModel):
device_id: Optional[str] = Field(default=None)
id_tags: list = Field(default_factory=list)
raw_id: Optional[str] = Field(default=None)
raw_id_type: Optional[str] = Field(default='cisco_aci')
raw_id_type: Optional[str] = Field(default='cisco-aci')
index: Optional[int] = Field(default=None)
name: Optional[str] = Field(default=None)
alias: Optional[str] = Field(default=None)
Expand Down Expand Up @@ -198,10 +274,46 @@ class InterfaceMetadataList(BaseModel):
interface_metadata: list = Field(default_factory=list)


class TopologyLinkDevice(BaseModel):
dd_id: Optional[str] = None
id: Optional[str] = None
id_type: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
ip_address: Optional[str] = None


class TopologyLinkInterface(BaseModel):
dd_id: Optional[str] = None
id: Optional[str] = None
id_type: Optional[str] = None
description: Optional[str] = None


class TopologyLinkSide(BaseModel):
device: Optional[TopologyLinkDevice] = None
interface: Optional[TopologyLinkInterface] = None


class SourceType(StrEnum):
LLDP = "lldp"
CDP = "cdp"
OTHER = "OTHER"


class TopologyLinkMetadata(BaseModel):
id: Optional[str] = None
source_type: Optional[SourceType] = Field(default=None)
local: Optional[TopologyLinkSide] = Field(default=None)
remote: Optional[TopologyLinkSide] = Field(default=None)
integration: Optional[str] = Field(default='cisco-aci')


class NetworkDevicesMetadata(BaseModel):
namespace: str = None
devices: Optional[list[DeviceMetadata]] = Field(default_factory=list)
interfaces: Optional[list[InterfaceMetadata]] = Field(default_factory=list)
links: Optional[list[TopologyLinkMetadata]] = Field(default_factory=list)
collect_timestamp: Optional[int] = None
size: Optional[int] = Field(default=0, exclude=True)

Expand All @@ -212,4 +324,6 @@ def append_metadata(self, metadata: DeviceMetadata | InterfaceMetadata):
self.devices.append(metadata)
if isinstance(metadata, InterfaceMetadata):
self.interfaces.append(metadata)
if isinstance(metadata, TopologyLinkMetadata):
self.links.append(metadata)
self.size += 1
Loading

0 comments on commit 693145c

Please sign in to comment.