bin/pvl.hosts-graph
changeset 405 97b436f9363a
parent 404 78e3d83135ab
child 406 92a4de88b86f
equal deleted inserted replaced
404:78e3d83135ab 405:97b436f9363a
   109 
   109 
   110     for host_name, attr, value in _load_snmp_data(options, file) :
   110     for host_name, attr, value in _load_snmp_data(options, file) :
   111         host = hosts.get(host_name)
   111         host = hosts.get(host_name)
   112         
   112         
   113         if value :
   113         if value :
   114             log.info("[%s] %s: %s", host, ' '.join(str(a) for a in attr), value)
   114             log.debug("[%s] %s: %s", host, ' '.join(str(a) for a in attr), value)
   115         else :
   115         else :
   116             log.info("[%s] %s", host, ' '.join(str(a) for a in attr),)
   116             log.debug("[%s] %s", host, ' '.join(str(a) for a in attr),)
   117 
   117 
   118         item = root.setdefault(host, { })
   118         item = root.setdefault(host, { })
   119         
   119         
   120         for a in attr[:-1] :
   120         for a in attr[:-1] :
   121             item = item.setdefault(a, {})
   121             item = item.setdefault(a, {})
   134         else :
   134         else :
   135             item[a] = value
   135             item[a] = value
   136             
   136             
   137     return root
   137     return root
   138 
   138 
   139 def search_vlans (snmp) :
   139 def host_vlans (host, host_vlans) :
   140     for host in snmp :
   140     """
   141         ports = set()
   141         {vlan: { tagged/untagged: [port] } } -> (port, (untag, [tag])).
   142         vlans_untagged = { }
   142     """
   143         vlans_tagged = collections.defaultdict(set)
   143 
   144 
   144     ports = set()
   145         if 'vlan' not in snmp[host] :
   145     vlans_untagged = { }
   146             continue
   146     vlans_tagged = collections.defaultdict(set)
   147 
   147 
   148         for vlan in snmp[host]['vlan'] :
   148     for vlan, vlan_attrs in host_vlans.iteritems() :
   149             for port in snmp[host]['vlan'][vlan].get('tagged', ()) :
   149         for port in vlan_attrs.get('tagged', ()) :
   150                 ports.add(port)
   150             ports.add(port)
   151                 vlans_tagged[port].add(vlan)
   151             vlans_tagged[port].add(vlan)
   152             
   152         
   153             for port in snmp[host]['vlan'][vlan].get('untagged', ()) :
   153         for port in vlan_attrs.get('untagged', ()) :
   154                 ports.add(port)
   154             ports.add(port)
   155                 vlans_untagged[port] = vlan
   155             vlans_untagged[port] = vlan
   156         
   156     
   157         out = { }
   157     for port in ports :
   158         for port in ports :
   158         untag = vlans_untagged.get(port)
   159             untag = vlans_untagged.get(port)
   159         tagged = vlans_tagged.get(port, ())
   160             tagged = vlans_tagged.get(port, ())
   160 
   161 
   161         log.debug("%s: %s: untag=%s tag=%s", host, port, untag, tagged)
   162             log.info("%s: %s: untag=%s tag=%s", host, port, untag, tagged)
   162         
   163 
   163         yield port, (untag, tagged)
   164             out[port] = (untag, tagged)
   164 
   165 
   165 def build_graph (snmp, hosts) :
   166         yield host, out
   166     """
   167 
   167         Combine given snmp data and { host: Host } into
   168 def search_lldp (snmp) :
   168             { node: label }
   169     lldp_hosts = {} 
   169             { (remote, remote_port, local_port, local): (local_untag, tagged, remote_untag) }
   170 
   170     """
   171     # first pass to resolve chassis ID's
   171 
       
   172     nodes = { }
       
   173     links = { }
       
   174     
       
   175     # first scan: lldp hosts
   172     for host, host_attrs in snmp.iteritems() :
   176     for host, host_attrs in snmp.iteritems() :
   173         lldp = host_attrs.get('lldp')
   177         lldp = host_attrs.get('lldp')
   174 
   178 
       
   179         if lldp :
       
   180             lldp_local = lldp['local']
       
   181             
       
   182             nodes[lldp_local['chassis']] = host.location
       
   183     
       
   184     # second scan: lldp remotes
       
   185     for host, host_attrs in snmp.iteritems() :
       
   186         lldp = host_attrs.get('lldp')
       
   187 
   175         if not lldp :
   188         if not lldp :
   176             continue
   189             continue
   177 
   190 
   178         lldp_local = lldp.get('local')
   191         local = lldp['local']['chassis']
   179 
   192 
   180         if not lldp_local :
   193         if 'vlan' in host_attrs :
   181             continue
   194             vlans = dict(host_vlans(host, host_attrs['vlan']))
   182 
   195         else :
   183         lldp_hosts[lldp_local['chassis']] = host
   196             vlans = None
   184 
   197         
   185     # second pass to resolve remote
   198         for port, port_attrs in lldp.get('port', { }).iteritems() :
   186     for host, host_attrs in snmp.iteritems() :
   199             local_port = port_attrs['local']['port']
   187         lldp = host_attrs.get('lldp')
   200 
   188 
   201             for remote, remote_attrs in port_attrs['remote'].iteritems() :
   189         if not lldp :
   202                 if remote not in nodes :
   190             continue
   203                     nodes[remote] = remote_attrs['sys_name']
   191 
   204 
   192         local_attrs = lldp.get('local')
   205                 remote_port = remote_attrs['port']
   193 
   206                 
   194         for port, port_attrs in snmp[host]['lldp']['port'].iteritems() :
   207                 # local vlans
   195             port_local_attrs = port_attrs['local']
   208                 if vlans :
   196 
   209                     port_vlans = vlans.get(port)
   197             for chassis, remote_attrs in port_attrs['remote'].iteritems() :
   210                 else :
   198                 remote_host = lldp_hosts.get(chassis)
   211                     port_vlans = None
   199 
   212 
   200                 local = merge(local_attrs, port_local_attrs, port_id=port)
   213                 if port_vlans :
   201                 remote = merge(remote_attrs, chassis=chassis)
   214                     local_untag, local_tagged = port_vlans
   202 
   215                 
   203                 log.info("%s @ %s %s : %s %s @ %s", host, host.location, local, remote, remote_host.location if remote_host else '', remote_host)
   216                 # bidirectional mappings
   204 
   217                 forward = (local, local_port, remote_port, remote)
   205                 yield host, local, remote, remote_host
   218                 reverse = (remote, remote_port, local_port, local)
   206 
   219 
   207 def apply_graph (options, items, vlans={}) :
   220                 if reverse not in links :
   208     import pydot
   221                     links[forward] = (local_untag, local_tagged, None)
   209 
   222                 else :
   210     dot = pydot.Dot(graph_name='lldp_hosts', graph_type='digraph',
   223                     remote_untag, remote_tagged, _ = links[reverse]
       
   224 
       
   225                     # merge
       
   226                     if remote_untag != local_untag :
       
   227                         log.warning("%s:%s untag %s <=> %s untag %s:%s",
       
   228                                 nodes[local], local_port, local_untag,
       
   229                                 remote_untag, nodes[remote], remote_port
       
   230                         )
       
   231 
       
   232                     if remote_tagged != local_tagged :
       
   233                         log.warning("%s:%s tagged %s <-> %s tagged %s:%s",
       
   234                                 nodes[local], local_port, ':'.join(str(x) for x in sorted(local_tagged)),
       
   235                                 ':'.join(str(x) for x in sorted(remote_tagged)), nodes[remote], remote_port
       
   236                         )
       
   237 
       
   238                     links[reverse] = (remote_untag, remote_tagged, local_untag)
       
   239 
       
   240     return nodes, links
       
   241 
       
   242 class GraphVlans (object) :
       
   243     """
       
   244         Maintain vlan -> dot style/color mappings
       
   245     """
       
   246 
       
   247     SERIES = 'paired12'
       
   248     NONE = 'black'
       
   249 
       
   250     def __init__ (self, vlans=None) :
       
   251         if vlans :
       
   252             self.vlans = dict(vlans)
       
   253         else :
       
   254             self.vlans = { }
       
   255     
       
   256     def color (self, vlan) :
       
   257         if vlan in self.vlans :
       
   258             return self.vlans[vlan]
       
   259         
       
   260         # alloc
       
   261         color = '/{series}/{index}'.format(series=self.SERIES, index=len(self.vlans) + 1)
       
   262 
       
   263         self.vlans[vlan] = color
       
   264 
       
   265         return color
       
   266 
       
   267 def dot_quote (value) :
       
   268     """
       
   269         Quote a dot value.
       
   270     """
       
   271 
       
   272     return '"{value}"'.format(value=value)
       
   273 
       
   274 def dot (*line, **attrs) :
       
   275     """
       
   276         Build dot-syntax:
       
   277             *line {
       
   278                 *line [**attrs];
       
   279             }
       
   280     """
       
   281 
       
   282     if line and attrs :
       
   283         return ''.join(('\t', ' '.join(str(x) for x in line), ' [',
       
   284             ', '.join('{name}="{value}"'.format(name=name, value=value) for name, value in attrs.iteritems()),
       
   285         ']', ';'))
       
   286     elif line :
       
   287         return ' '.join(line) + ' {'
       
   288     else :
       
   289         return '}'
       
   290 
       
   291 def build_dot (options, nodes, links, type='digraph', vlans=None) :
       
   292     """
       
   293         Construct a dot description of the given node/links graph.
       
   294     """
       
   295 
       
   296     if vlans is True :
       
   297         vlans = { }
       
   298 
       
   299     yield dot(type, 'verkko')
       
   300 
       
   301     # defaults
       
   302     yield dot('graph',
   211             # XXX: breaks multi-edges?
   303             # XXX: breaks multi-edges?
   212             #splines     = 'true',
   304             #splines     = 'true',
   213 
   305 
   214             sep             = '+25,25',
   306             sep             = '+25,25',
   215             overlap         = 'scalexy',
   307             overlap         = 'scalexy',
   216 
   308 
   217             # only applies to loops
   309             # only applies to loops
   218             nodesep     = 0.5,
   310             nodesep     = 0.5,
   219     )
   311     )
   220     dot.set_edge_defaults(
   312     yield dot('edge',
   221             labeldistance   = 3.0,
   313         labeldistance   = 3.0,
   222             penwidth        = 2.0,
   314         penwidth        = 2.0,
   223     )
   315     )
   224 
   316     yield dot('node',
   225     nodes = { }
   317         fontsize        = 18,
   226     edges = { }
   318     )
   227     vlan_colors = { } # { vlan: color }
   319     
   228 
   320     # nodes
   229     for host, local, remote, remote_host in items :
   321     for node, node_label in nodes.iteritems() :
   230         # src
   322         yield dot(dot_quote(node), label=node_label)
   231         src_name = str(host)
   323 
   232         src_label = '"{host.location}"'.format(host=host)
   324     # links
   233 
   325     for (local, local_port, remote_port, remote), (local_untag, tagged, remote_untag) in links.iteritems() :
   234         if src_name in nodes :
   326         if vlans :
   235             src = nodes[src_name]
   327             head_color = vlans.color(local_untag) if local_untag else None
   236         else :
   328             tail_color = vlans.color(remote_untag) if remote_untag else None
   237             src = nodes[src_name] = pydot.Node(src_name,
   329             line_colors = [vlans.color(tag) for tag in sorted(tagged)]
   238                     label       = src_label,
   330         else :
   239                     fontsize    = 18,
   331             head_color = GraphVlans.NONE if local_untag else None
   240             )
   332             tail_color = GraphVlans.NONE if remote_untag else None
   241             dot.add_node(src)
   333             line_colors = []
   242         
   334 
   243         # dst
   335         if head_color and tail_color :
   244         if remote_host :
   336             dir = 'both'
   245             dst_name = str(remote_host)
   337             colors = [head_color, tail_color] + line_colors
   246             dst_label = '"{host.location}"'.format(host=remote_host)
   338         elif head_color :
   247         else :
   339             dir = 'forward'
   248             dst_name = remote['chassis'].replace(':', '-')
   340             colors = [head_color] + line_colors
   249 
   341         elif tail_color :
   250             # XXX: pydot is not smart enough to quote this
   342             dir = 'back'
   251             if remote['sys_name'] :
   343             colors = [vlans.NONE, tail_color] + line_colors
   252                 dst_label = '"{remote[chassis]} ({remote[sys_name]})"'.format(remote=remote)
   344         else :
   253             else :
   345             dir = 'none'
   254                 dst_label = '"{remote[chassis]}"'.format(remote=remote)
   346             colors = line_colors
   255         
   347 
   256         if dst_name in nodes :
   348         yield dot(dot_quote(local), '->', dot_quote(remote),
   257             dst = nodes[dst_name]
   349             taillabel   = local_port,
   258         else :
   350             headlabel   = remote_port,
   259             dst = nodes[dst_name] = pydot.Node(dst_name,
   351             dir         = dir,
   260                     label       = dst_label,
   352 
   261                     fontsize    = 18,
   353             fillcolor   = 'black',
   262             )
   354             color       = ':'.join(colors),
   263             dot.add_node(dst)
   355         )
   264 
   356 
   265         # edges
   357     yield dot()
   266         headlabel = '"{remote[port]}"'.format(remote=remote)
   358 
   267         taillabel = '"{local[port]}"'.format(local=local)
   359 def apply_dot (options, file, dot) :
   268         fillcolor = 'black'
   360     """
   269         color = 'black'
   361         Output dot file for given graphbits
   270         untag = tagged = None
   362     """
   271 
   363 
   272         # vlans?
   364     for line in dot :
   273         if vlans and host in vlans and local['port_id'] in vlans[host] :
   365         file.write(line + '\n')
   274             untag, tagged = vlans[host][local['port_id']]
       
   275             
       
   276             log.debug("%s#%s: %s+%s", host, local['port_id'], untag, tagged)
       
   277             
       
   278             colors = []
       
   279             for tag in sorted(tagged) :
       
   280                 if tag in COLOR_VLANS :
       
   281                     colors.append(COLOR_VLANS[tag])
       
   282                 elif tag in vlan_colors :
       
   283                     colors.append(vlan_colors[tag])
       
   284                 else :
       
   285                     color = '/paired12/{count}'.format(count=1+len(vlan_colors))
       
   286                     
       
   287                     log.info("%s#%s: chosing new vlan %s color %s", host, local['port_id'], tag, color)
       
   288 
       
   289                     vlan_colors[tag] = color
       
   290                     colors.append(color)
       
   291 
       
   292             if not untag :
       
   293                 pass
       
   294             elif untag in COLOR_VLANS :
       
   295                 fillcolor = COLOR_VLANS[untag]
       
   296             elif untag in vlan_colors :
       
   297                 fillcolor = vlan_colors[untag]
       
   298             else :
       
   299                 color = '/paired12/{count}'.format(count=1+len(vlan_colors))
       
   300 
       
   301                 log.warn("%s#%s: chosing new vlan %s color %s", host, local['port_id'], untag, color)
       
   302                     
       
   303                 fillcolor = vlan_colors[tag] = color
       
   304             
       
   305             # first color overrides fillcolor for heads
       
   306             if colors and untag :
       
   307                 color = ':'.join([fillcolor] + colors)
       
   308             elif colors :
       
   309                 color = ':'.join(colors)
       
   310             elif fillcolor :
       
   311                 color = fillcolor
       
   312 
       
   313         elif vlans :
       
   314             # XXX: this happens when LLDP gives us the LACP ports but the VLANS are on the TRK port
       
   315             log.warn("%s#%s: unknown port for vlans: %s", host, local['port_id'], vlans.get(host))
       
   316 
       
   317         # edge
       
   318         if (src_name, local['port'], dst_name, remote['port']) in edges :
       
   319             log.warning("%s:%s <- %s:%s: duplicate", src_name, local['port'], dst_name, remote['port'])
       
   320         
       
   321         elif (dst_name, remote['port'], src_name, local['port']) in edges :
       
   322             log.info("%s <-> %s", src_name, dst_name)
       
   323             edge = edges[(dst_name, remote['port'], src_name, local['port'])]
       
   324 
       
   325             if edge.get('headlabel') != taillabel :
       
   326                 log.warn("%s -> %s: local port mismatch: %s vs %s", src_name, dst_name, local['port'], edge.get('headlabel'))
       
   327             
       
   328             if edge.get('taillabel') != headlabel :
       
   329                 log.warn("%s -> %s: remote port mismatch: %s vs %s", src_name, dst_name, remote['port'], edge.get('taillabel'))
       
   330 
       
   331             if edge.get('fillcolor') != fillcolor :
       
   332                 log.warn("%s#%s -> %s#%s: remote untag mismatch: %s vs %s", src_name, local['port'], dst_name, remote['port'], fillcolor, edge.get('fillcolor'))
       
   333 
       
   334             if edge.get('color') != '"' + color + '"' :
       
   335                 log.warn("%s#%s -> %s#%s: remote tagged mismatch: %s vs %s", src_name, local['port'], dst_name, remote['port'], color, edge.get('color'))
       
   336 
       
   337             # mark as bidirectional
       
   338             edges[(src_name, local['port'], dst_name, remote['port'])] = edge
       
   339             
       
   340             edge.set('dir', 'both' if untag else 'none')
       
   341 
       
   342             # set second color for tail
       
   343             if untag :
       
   344                 edge.set('color', '"{headcolor}:{tailcolor}{tagcolors}"'.format(
       
   345                     headcolor   = edge.get('fillcolor'),
       
   346                     tailcolor   = fillcolor,
       
   347                     tagcolors   = ':' + ':'.join(colors) if colors else '',
       
   348                 ))
       
   349 
       
   350         else :
       
   351             edge = edges[(src_name, local['port'], dst_name, remote['port'])] = pydot.Edge(src, dst,
       
   352                     dir         = 'forward' if untag else 'none',
       
   353                     headlabel   = headlabel,
       
   354                     taillabel   = taillabel,
       
   355                     color       = '"{color}"'.format(color=color),
       
   356                     fillcolor   = fillcolor,
       
   357             )
       
   358 
       
   359             dot.add_edge(edge)
       
   360 
       
   361     if options.graph_dot :
       
   362         dot.write(options.graph_dot)
       
   363 
   366 
   364 def main (argv) :
   367 def main (argv) :
   365     """
   368     """
   366         Graph network
   369         Graph network
   367     """
   370     """
   374             help="Load snmp data from FILE")
   377             help="Load snmp data from FILE")
   375 
   378 
   376     parser.add_option('--graph-dot', metavar='FILE',
   379     parser.add_option('--graph-dot', metavar='FILE',
   377             help="Output .dot graph data to file")
   380             help="Output .dot graph data to file")
   378 
   381 
   379     parser.add_option('--graph-vlans', action='store_true', dest='vlans', 
   382     parser.add_option('--graph-vlans', action='store_true', dest='graph_vlans', 
   380             help="Graph all VLANs")
   383             help="Graph all VLANs")
   381 
   384 
   382     parser.add_option('--no-vlans', action='store_false', dest='vlans',
   385     parser.add_option('--no-vlans', action='store_false', dest='graph_vlans',
   383             help="Do not color VLANs")
   386             help="Do not color VLANs")
   384 
   387 
   385     # input
   388     # input
   386     options, args = parser.parse_args(argv[1:])
   389     options, args = parser.parse_args(argv[1:])
   387     pvl.args.apply(options)
   390     pvl.args.apply(options)
   390     hosts = dict((str(host), host) for host in pvl.hosts.apply(options, args))
   393     hosts = dict((str(host), host) for host in pvl.hosts.apply(options, args))
   391 
   394 
   392     # load raw snmp data
   395     # load raw snmp data
   393     snmp = load_snmp_data(options, options.snmp_data, hosts)
   396     snmp = load_snmp_data(options, options.snmp_data, hosts)
   394 
   397 
   395     # TODO: process data into graph
   398     # process data into graph
   396 
   399     nodes, links = build_graph(snmp, hosts)
   397     # XXX: compat
   400     
   398     vlans = dict(search_vlans(snmp))
   401     # process graph into dot
   399     lldp = list(search_lldp(snmp))
   402     if options.graph_vlans is False :
   400     
   403         graph_vlans = None
   401     # generate graph
   404     else :
   402     apply_graph(options, lldp, vlans if options.vlans else None)
   405         graph_vlans = GraphVlans()
       
   406 
       
   407     if options.graph_dot :
       
   408         # process to dot
       
   409         dot = build_dot(options, nodes, links, vlans=graph_vlans)
       
   410         
       
   411         # write out
       
   412         apply_dot(options, open(options.graph_dot, 'w'), dot)
   403 
   413 
   404     return 0
   414     return 0
   405 
   415 
   406 if __name__ == '__main__':
   416 if __name__ == '__main__':
   407     pvl.args.main(main)
   417     pvl.args.main(main)