bin/pvl.hosts-lldp
author Tero Marttila <terom@paivola.fi>
Tue, 18 Mar 2014 21:25:35 +0200
changeset 398 de275bf6db70
parent 395 9de553b50128
permissions -rw-r--r--
pvl.hosts-lldp: handle non-lldp-supporting switches, use dot1q per default for (port, vlan) leaf node mappings, with fallback to dot1d
#!/usr/bin/env python

"""
    Requirements:
        pydot
"""

import pvl.args
import pvl.hosts
from pvl.invoke import merge
from pvl.snmp import snmp, lldp, vlan, bridge

import logging; log = logging.getLogger('pvl.hosts-lldp')
import optparse

def hosts_snmp (options, hosts) :
    """
        Discover SNMP-supporting hosts.

        Yields Host, snmpdata
    """

    for host in hosts :
        host_snmp = host.extensions.get('snmp')

        if not host_snmp :
            log.debug("%s: skip non-snmp host", host)
            continue

        elif host.down :
            log.debug("%s: skip down host", host)
            continue

        else :
            log.debug("%s: %s", host, host_snmp)

        yield host, host_snmp

def hosts_lldp (options, hosts) :
    """
        Discover LLDP-supporting hosts.

        Yields Host, LLDPAgent
    """

    for host, host_snmp in hosts_snmp(options, hosts) :
        agent = lldp.LLDPAgent.apply(options, host.fqdn(), community=host_snmp.get('community'))

        try :
            local = agent.local
        except snmp.SNMPError as ex :
            log.warning("%s: %s", host, ex)
            continue
        
        if not local :
            log.info("%s: no lldp support", host)
            continue

        log.info("%s: %s", host, local)

        if local['sys_name'] != host.host :
            log.warning("%s: SNMP sys_name mismatch: %s", host, local['sys_name'])

        yield host, agent

def hosts_vlan (options, hosts) :
    """
        Discover VLAN-supporting hosts.

        Yields Host, VLANAgent
    """

    for host, host_snmp in hosts_snmp(options, hosts) :
        agent = vlan.VLANAgent.apply(options, host.fqdn(), community=host_snmp.get('community'))

        try :
            count = agent.count
        except snmp.SNMPError as ex :
            log.warning("%s: %s", host, ex)
            continue

        log.info("%s: %s", host, count)

        yield host, agent

def hosts_bridge (options, hosts) :
    """
        Discover Bridge-supporting hosts.

        Yields Host, BridgeAgent
    """

    for host, host_snmp in hosts_snmp(options, hosts) :
        agent = bridge.BridgeAgent.apply(options, host.fqdn(), community=host_snmp.get('community'))

        try :
            agent.ping()
        except snmp.SNMPError as ex :
            log.warning("%s: %s", host, ex)
            continue

        log.info("%s", host)

        yield host, agent


def apply_hosts_lldp (options, hosts) :
    """
        Query host LLDP info.
    """
    
    _hosts_lldp = list(hosts_lldp(options, hosts))
    hosts_by_chassis = { }
    
    # first pass to discover hosts
    for host, agent in _hosts_lldp :
        chassis = agent.local['chassis']
        log.info("%s: %s", host, chassis)

        hosts_by_chassis[chassis] = host
    
    # second pass to discver links
    for host, agent in _hosts_lldp :
        try :
            remotes = list(agent.remotes())
        except snmp.SNMPError as ex :
            log.warn("%s: broken lldp remotes: %s", host, ex)
            continue

        for port, remote in remotes :
            port = agent.port(port)

            remote_chassis = remote['chassis']
            remote_host = hosts_by_chassis.get(remote_chassis)
            
            log.info("%s: %s: %s (%s)", host, port, remote, remote_host)

            yield host, merge(agent.local, port), remote, remote_host

import collections

def apply_hosts_vlan (options, hosts) :
    """
        Query host VLAN ports.

        Yields host, { port: (untagged, [tagged]) }
    """

    _hosts_vlan = list(hosts_vlan(options, hosts))

    for host, agent in _hosts_vlan :
        # only one untagged vlan / port
        vlan_untagged = { }
            
        # multiple taggd vlans / port
        vlan_tagged = collections.defaultdict(set)

        for vlan, (tagged, untagged) in agent.vlan_ports() :
            log.info("%s: %s: %s + %s", host, vlan, tagged, untagged)
            
            for port in tagged :
                vlan_tagged[port].add(vlan)
            
            for port in untagged :
                if port in vlan_untagged :
                    log.warning("%s: duplicate untagged vlan %s for port %s on vlan %s", host, vlan, port, vlan_untagged[port])

                vlan_untagged[port] = vlan
            
        # pack into {port: (untagged, [tagged]) }
        yield host, dict(
            (
                    port, (vlan_untagged.get(port), tuple(vlan_tagged[port]))
            ) for port in set(vlan_untagged) | set(vlan_tagged)
        )

def apply_hosts_bridge (options, hosts) :
    """
        Query host bridge tables.

        Yields host, { port: (macs) }
    """

    for host, agent in hosts_bridge(options, hosts) :
        ports = collections.defaultdict(list)

        try :
            vlan_fdb_ports = list(agent.vlan_fdb_ports())
        except snmp.SNMPError as ex :
            log.warn("%s: broken dot1q fdb: %s", host, ex)
            continue

        if vlan_fdb_ports :
            log.info("%s: have dot1q ports", host)

            for ether, port, vlan in agent.vlan_fdb_ports() :
                if not port :
                    # XXX: unknown?
                    continue

                ports[(port, vlan)].append(ether)
        else :
            try :
                fdb_ports = list(agent.fdb_ports())
            except snmp.SNMPError as ex :
                log.warn("%s: broken dot1q fdb: %s", host, ex)
                continue
            
            # fallback to dot1d fdb
            log.info("%s: fallback to dot1d", host)

            for ether, port in agent.fdb_ports() :
                if not port :
                    # XXX: unknown?
                    continue

                ports[(port, None)].append(ether)

        yield host, ports

COLOR_VLANS = {
    1:      'grey',         # pvl-lan
    2:      'blue',         # pvl-lan2
    3:      'red',          # pvl-san
    4:      'green',        # pvl-veturi
    7:      'orange',       # pvl-ranssi
    8:      'yellow',       # pvl-mgmt
    10:     'brown',        # pvl-public
    100:    'navyblue',     # pvl-test
    103:    'red4',         # pvl-test-san
    104:    'red2',         # pvl-ganeti
    192:    'purple',       # paivola-services
    255:    'magenta',      # pvl-sonera
}

def apply_graph (options, items, vlans={}) :
    import pydot

    dot = pydot.Dot(graph_name='lldp_hosts', graph_type='digraph',
            # XXX: breaks multi-edges?
            #splines     = 'true',

            sep             = '+25,25',
            overlap         = 'scalexy',

            # only applies to loops
            nodesep     = 0.5,
    )
    dot.set_edge_defaults(
            labeldistance   = 3.0,
            penwidth        = 2.0,
    )

    nodes = { }
    edges = { }
    vlan_colors = { } # { vlan: color }

    for host, local, remote, remote_host in items :
        # src
        src_name = str(host)
        src_label = '"{host.location}"'.format(host=host)

        if src_name in nodes :
            src = nodes[src_name]
        else :
            src = nodes[src_name] = pydot.Node(src_name,
                    label       = src_label,
                    fontsize    = 18,
            )
            dot.add_node(src)
        
        # dst
        if remote_host :
            dst_name = str(remote_host)
            dst_label = '"{host.location}"'.format(host=remote_host)
        else :
            dst_name = remote['chassis'].replace(':', '-')

            # XXX: pydot is not smart enough to quote this
            if remote['sys_name'] :
                dst_label = '"{remote[chassis]} ({remote[sys_name]})"'.format(remote=remote)
            else :
                dst_label = '"{remote[chassis]}"'.format(remote=remote)
        
        if dst_name in nodes :
            dst = nodes[dst_name]
        else :
            dst = nodes[dst_name] = pydot.Node(dst_name,
                    label       = dst_label,
                    fontsize    = 18,
            )
            dot.add_node(dst)

        # edges
        headlabel = '"{remote[port]}"'.format(remote=remote)
        taillabel = '"{local[port]}"'.format(local=local)
        fillcolor = 'black'
        color = 'black'
        untag = tagged = None

        # vlans?
        if vlans and host in vlans and local['port_id'] in vlans[host] :
            untag, tagged = vlans[host][local['port_id']]
            
            log.debug("%s#%s: %s+%s", host, local['port_id'], untag, tagged)
            
            colors = []
            for tag in sorted(tagged) :
                if tag in COLOR_VLANS :
                    colors.append(COLOR_VLANS[tag])
                elif tag in vlan_colors :
                    colors.append(vlan_colors[tag])
                else :
                    color = '/paired12/{count}'.format(count=1+len(vlan_colors))
                    
                    log.info("%s#%s: chosing new vlan %s color %s", host, local['port_id'], tag, color)

                    vlan_colors[tag] = color
                    colors.append(color)

            if not untag :
                pass
            elif untag in COLOR_VLANS :
                fillcolor = COLOR_VLANS[untag]
            elif untag in vlan_colors :
                fillcolor = vlan_colors[untag]
            else :
                color = '/paired12/{count}'.format(count=1+len(vlan_colors))

                log.warn("%s#%s: chosing new vlan %s color %s", host, local['port_id'], untag, color)
                    
                fillcolor = vlan_colors[tag] = color
            
            # first color overrides fillcolor for heads
            if colors and untag :
                color = ':'.join([fillcolor] + colors)
            elif colors :
                color = ':'.join(colors)
            elif fillcolor :
                color = fillcolor

        elif vlans :
            # XXX: this happens when LLDP gives us the LACP ports but the VLANS are on the TRK port
            log.warn("%s#%s: unknown port for vlans: %s", host, local['port_id'], vlans.get(host))

        # edge
        if (src_name, local['port'], dst_name, remote['port']) in edges :
            log.warning("%s:%s <- %s:%s: duplicate", src_name, local['port'], dst_name, remote['port'])
        
        elif (dst_name, remote['port'], src_name, local['port']) in edges :
            log.info("%s <-> %s", src_name, dst_name)
            edge = edges[(dst_name, remote['port'], src_name, local['port'])]

            if edge.get('headlabel') != taillabel :
                log.warn("%s -> %s: local port mismatch: %s vs %s", src_name, dst_name, local['port'], edge.get('headlabel'))
            
            if edge.get('taillabel') != headlabel :
                log.warn("%s -> %s: remote port mismatch: %s vs %s", src_name, dst_name, remote['port'], edge.get('taillabel'))

            if edge.get('fillcolor') != fillcolor :
                log.warn("%s#%s -> %s#%s: remote untag mismatch: %s vs %s", src_name, local['port'], dst_name, remote['port'], fillcolor, edge.get('fillcolor'))

            if edge.get('color') != '"' + color + '"' :
                log.warn("%s#%s -> %s#%s: remote tagged mismatch: %s vs %s", src_name, local['port'], dst_name, remote['port'], color, edge.get('color'))

            # mark as bidirectional
            edges[(src_name, local['port'], dst_name, remote['port'])] = edge
            
            edge.set('dir', 'both' if untag else 'none')

            # set second color for tail
            if untag :
                edge.set('color', '"{headcolor}:{tailcolor}{tagcolors}"'.format(
                    headcolor   = edge.get('fillcolor'),
                    tailcolor   = fillcolor,
                    tagcolors   = ':' + ':'.join(colors) if colors else '',
                ))

        else :
            edge = edges[(src_name, local['port'], dst_name, remote['port'])] = pydot.Edge(src, dst,
                    dir         = 'forward' if untag else 'none',
                    headlabel   = headlabel,
                    taillabel   = taillabel,
                    color       = '"{color}"'.format(color=color),
                    fillcolor   = fillcolor,
            )

            dot.add_edge(edge)

    if options.graph_dot :
        dot.write(options.graph_dot)

def main (argv) :
    """
        SNMP polling.
    """

    parser = optparse.OptionParser(main.__doc__)
    parser.add_option_group(pvl.args.parser(parser))
    parser.add_option_group(pvl.hosts.optparser(parser))
    parser.add_option_group(pvl.snmp.snmp.options(parser))

    parser.add_option('--graph-dot',
            help="Output .dot graph data")

    parser.add_option('--no-vlans', action='store_const', dest='vlans', const=False,
            help="Do not color VLANs")

    # input
    options, args = parser.parse_args(argv[1:])
    pvl.args.apply(options)

    hosts = pvl.hosts.apply(options, args)
    
    # lookup host-port-vlan mappings
    if options.vlans is False :
        vlans = None
    else :
        vlans = dict(apply_hosts_vlan(options, hosts))

    # discover node/port graph
    items = apply_hosts_lldp(options, hosts)

    # discover edge nodes
    leafs = apply_hosts_bridge(options, hosts)

    # print
    if options.graph_dot :
        apply_graph(options, items, vlans)
    else :
        for host, local, remote, remote_host in items :
            if remote_host :
                print "{host:30} {host.location:>30} {local[port]:>25} <-> {remote[port]:<25} {remote_host.location:>30} # {remote[chassis]} ({remote_host})".format(host=host, local=local, remote=remote, remote_host=remote_host)
            else :
                print "{host:30} {host.location:>30} {local[port]:>25} --> {remote[port]:<25} {empty:30} # {remote[chassis]} ({remote[sys_name]})".format(host=host, local=local, remote=remote, empty='')
       
        for host, ports in vlans.iteritems() :
            for port, (untag, tagged) in ports.iteritems() :
                print "{host:30} {host.location:>30} {port:25} {untag}{tagged}".format(host=host, port=port,
                        untag       = '({untag}) '.format(untag=untag) if untag else '',
                        tagged      = ' '.join('<{tag}>'.format(tag=tag) for tag in tagged),
                )

        for host, ports in leafs :
            for (port, vlan), ethers in ports.iteritems() :
                print "{host:30} {host.location:>30} {port:25} <-- {vlan} # {ethers}".format(
                        host        = host,
                        port        = port,
                        vlan        = '<{vlan}>'.format(vlan=vlan) if vlan else '',
                        ethers      = ' '.join(ethers),
                )

if __name__ == '__main__':
    pvl.args.main(main)