Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NDM] [Cisco ACI] Support submitting topology metadata #18675

Merged
merged 12 commits into from
Dec 6, 2024
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
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
Loading