uptodateness: extract function get_utdv_edges
[metze/samba/wip.git] / python / samba / netcmd / visualize.py
1 # Visualisation tools
2 #
3 # Copyright (C) Andrew Bartlett 2015, 2018
4 #
5 # by Douglas Bagnall <douglas.bagnall@catalyst.net.nz>
6 #
7 # This program is free software; you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 from __future__ import print_function
21
22 import os
23 import sys
24 from collections import defaultdict
25 import subprocess
26
27 import tempfile
28 import samba.getopt as options
29 from samba import dsdb
30 from samba import nttime2unix
31 from samba.netcmd import Command, SuperCommand, CommandError, Option
32 from samba.samdb import SamDB
33 from samba.graph import dot_graph
34 from samba.graph import distance_matrix, COLOUR_SETS
35 from samba.graph import full_matrix
36 from ldb import SCOPE_BASE, SCOPE_SUBTREE, LdbError
37 import time
38 import re
39 from samba.kcc import KCC, ldif_import_export
40 from samba.kcc.kcc_utils import KCCError
41 from samba.compat import text_type
42 from samba.uptodateness import (
43     get_partition_maps,
44     get_partition,
45     get_own_cursor,
46     get_utdv,
47     get_utdv_edges,
48 )
49
50 COMMON_OPTIONS = [
51     Option("-H", "--URL", help="LDB URL for database or target server",
52            type=str, metavar="URL", dest="H"),
53     Option("-o", "--output", help="write here (default stdout)",
54            type=str, metavar="FILE", default=None),
55     Option("--distance", help="Distance matrix graph output (default)",
56            dest='format', const='distance', action='store_const'),
57     Option("--utf8", help="Use utf-8 Unicode characters",
58            action='store_true'),
59     Option("--color", help="use color (yes, no, auto)",
60            choices=['yes', 'no', 'auto']),
61     Option("--color-scheme", help=("use this colour scheme "
62                                    "(implies --color=yes)"),
63            choices=list(COLOUR_SETS.keys())),
64     Option("-S", "--shorten-names",
65            help="don't print long common suffixes",
66            action='store_true', default=False),
67     Option("-r", "--talk-to-remote", help="query other DCs' databases",
68            action='store_true', default=False),
69     Option("--no-key", help="omit the explanatory key",
70            action='store_false', default=True, dest='key'),
71 ]
72
73 DOT_OPTIONS = [
74     Option("--dot", help="Graphviz dot output", dest='format',
75            const='dot', action='store_const'),
76     Option("--xdot", help="attempt to call Graphviz xdot", dest='format',
77            const='xdot', action='store_const'),
78 ]
79
80 TEMP_FILE = '__temp__'
81
82
83 class GraphCommand(Command):
84     """Base class for graphing commands"""
85
86     synopsis = "%prog [options]"
87     takes_optiongroups = {
88         "sambaopts": options.SambaOptions,
89         "versionopts": options.VersionOptions,
90         "credopts": options.CredentialsOptions,
91     }
92     takes_options = COMMON_OPTIONS + DOT_OPTIONS
93     takes_args = ()
94
95     def get_db(self, H, sambaopts, credopts):
96         lp = sambaopts.get_loadparm()
97         creds = credopts.get_credentials(lp, fallback_machine=True)
98         samdb = SamDB(url=H, credentials=creds, lp=lp)
99         return samdb
100
101     def get_kcc_and_dsas(self, H, lp, creds):
102         """Get a readonly KCC object and the list of DSAs it knows about."""
103         unix_now = int(time.time())
104         kcc = KCC(unix_now, readonly=True)
105         kcc.load_samdb(H, lp, creds)
106
107         dsa_list = kcc.list_dsas()
108         dsas = set(dsa_list)
109         if len(dsas) != len(dsa_list):
110             print("There seem to be duplicate dsas", file=sys.stderr)
111
112         return kcc, dsas
113
114     def write(self, s, fn=None, suffix='.dot'):
115         """Decide whether we're dealing with a filename, a tempfile, or
116         stdout, and write accordingly.
117
118         :param s: the string to write
119         :param fn: a destination
120         :param suffix: suffix, if destination is a tempfile
121
122         If fn is None or "-", write to stdout.
123         If fn is visualize.TEMP_FILE, write to a temporary file
124         Otherwise fn should be a filename to write to.
125         """
126         if fn is None or fn == '-':
127             # we're just using stdout (a.k.a self.outf)
128             print(s, file=self.outf)
129             return
130
131         if fn is TEMP_FILE:
132             fd, fn = tempfile.mkstemp(prefix='samba-tool-visualise',
133                                       suffix=suffix)
134             f = open(fn, 'w')
135             os.close(fd)
136         else:
137             f = open(fn, 'w')
138
139         f.write(s)
140         f.close()
141         return fn
142
143     def calc_output_format(self, format, output):
144         """Heuristics to work out what output format was wanted."""
145         if not format:
146             # They told us nothing! We have to work it out for ourselves.
147             if output and output.lower().endswith('.dot'):
148                 return 'dot'
149             else:
150                 return 'distance'
151
152         if format == 'xdot':
153             return 'dot'
154
155         return format
156
157     def call_xdot(self, s, output):
158         if output is None:
159             fn = self.write(s, TEMP_FILE)
160         else:
161             fn = self.write(s, output)
162         xdot = os.environ.get('SAMBA_TOOL_XDOT_PATH', '/usr/bin/xdot')
163         subprocess.call([xdot, fn])
164         os.remove(fn)
165
166     def calc_distance_color_scheme(self, color, color_scheme, output):
167         """Heuristics to work out the colour scheme for distance matrices.
168         Returning None means no colour, otherwise it sould be a colour
169         from graph.COLOUR_SETS"""
170         if color == 'no':
171             return None
172
173         if color == 'auto':
174             if isinstance(output, str) and output != '-':
175                 return None
176             if not hasattr(self.outf, 'isatty'):
177                 # not a real file, perhaps cStringIO in testing
178                 return None
179             if not self.outf.isatty():
180                 return None
181
182         if color_scheme is None:
183             if '256color' in os.environ.get('TERM', ''):
184                 return 'xterm-256color-heatmap'
185             return 'ansi'
186
187         return color_scheme
188
189
190 def get_dnstr_site(dn):
191     """Helper function for sorting and grouping DNs by site, if
192     possible."""
193     m = re.search(r'CN=Servers,CN=\s*([^,]+)\s*,CN=Sites', dn)
194     if m:
195         return m.group(1)
196     # Oh well, let it sort by DN
197     return dn
198
199
200 def get_dnstrlist_site(t):
201     """Helper function for sorting and grouping lists of (DN, ...) tuples
202     by site, if possible."""
203     return get_dnstr_site(t[0])
204
205
206 def colour_hash(x):
207     """Generate a randomish but consistent darkish colour based on the
208     given object."""
209     from hashlib import md5
210     tmp_str = str(x)
211     if isinstance(tmp_str, text_type):
212         tmp_str = tmp_str.encode('utf8')
213     c = int(md5(tmp_str).hexdigest()[:6], base=16) & 0x7f7f7f
214     return '#%06x' % c
215
216
217 class cmd_reps(GraphCommand):
218     "repsFrom/repsTo from every DSA"
219
220     takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
221         Option("-p", "--partition", help="restrict to this partition",
222                default=None),
223     ]
224
225     def run(self, H=None, output=None, shorten_names=False,
226             key=True, talk_to_remote=False,
227             sambaopts=None, credopts=None, versionopts=None,
228             mode='self', partition=None, color=None, color_scheme=None,
229             utf8=None, format=None, xdot=False):
230         # We use the KCC libraries in readonly mode to get the
231         # replication graph.
232         lp = sambaopts.get_loadparm()
233         creds = credopts.get_credentials(lp, fallback_machine=True)
234         local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
235         unix_now = local_kcc.unix_now
236
237         partition = get_partition(local_kcc.samdb, partition)
238
239         # nc_reps is an autovivifying dictionary of dictionaries of lists.
240         # nc_reps[partition]['current' | 'needed'] is a list of
241         # (dsa dn string, repsFromTo object) pairs.
242         nc_reps = defaultdict(lambda: defaultdict(list))
243
244         guid_to_dnstr = {}
245
246         # We run a new KCC for each DSA even if we aren't talking to
247         # the remote, because after kcc.run (or kcc.list_dsas) the kcc
248         # ends up in a messy state.
249         for dsa_dn in dsas:
250             kcc = KCC(unix_now, readonly=True)
251             if talk_to_remote:
252                 res = local_kcc.samdb.search(dsa_dn,
253                                              scope=SCOPE_BASE,
254                                              attrs=["dNSHostName"])
255                 dns_name = str(res[0]["dNSHostName"][0])
256                 print("Attempting to contact ldap://%s (%s)" %
257                       (dns_name, dsa_dn),
258                       file=sys.stderr)
259                 try:
260                     kcc.load_samdb("ldap://%s" % dns_name, lp, creds)
261                 except KCCError as e:
262                     print("Could not contact ldap://%s (%s)" % (dns_name, e),
263                           file=sys.stderr)
264                     continue
265
266                 kcc.run(H, lp, creds)
267             else:
268                 kcc.load_samdb(H, lp, creds)
269                 kcc.run(H, lp, creds, forced_local_dsa=dsa_dn)
270
271             dsas_from_here = set(kcc.list_dsas())
272             if dsas != dsas_from_here:
273                 print("found extra DSAs:", file=sys.stderr)
274                 for dsa in (dsas_from_here - dsas):
275                     print("   %s" % dsa, file=sys.stderr)
276                 print("missing DSAs (known locally, not by %s):" % dsa_dn,
277                       file=sys.stderr)
278                 for dsa in (dsas - dsas_from_here):
279                     print("   %s" % dsa, file=sys.stderr)
280
281             for remote_dn in dsas_from_here:
282                 if mode == 'others' and remote_dn == dsa_dn:
283                     continue
284                 elif mode == 'self' and remote_dn != dsa_dn:
285                     continue
286
287                 remote_dsa = kcc.get_dsa('CN=NTDS Settings,' + remote_dn)
288                 kcc.translate_ntdsconn(remote_dsa)
289                 guid_to_dnstr[str(remote_dsa.dsa_guid)] = remote_dn
290                 # get_reps_tables() returns two dictionaries mapping
291                 # dns to NCReplica objects
292                 c, n = remote_dsa.get_rep_tables()
293                 for part, rep in c.items():
294                     if partition is None or part == partition:
295                         nc_reps[part]['current'].append((dsa_dn, rep))
296                 for part, rep in n.items():
297                     if partition is None or part == partition:
298                         nc_reps[part]['needed'].append((dsa_dn, rep))
299
300         all_edges = {'needed': {'to': [], 'from': []},
301                      'current': {'to': [], 'from': []}}
302
303         short_partitions, long_partitions = get_partition_maps(local_kcc.samdb)
304
305         for partname, part in nc_reps.items():
306             for state, edgelists in all_edges.items():
307                 for dsa_dn, rep in part[state]:
308                     short_name = long_partitions.get(partname, partname)
309                     for r in rep.rep_repsFrom:
310                         edgelists['from'].append(
311                             (dsa_dn,
312                              guid_to_dnstr[str(r.source_dsa_obj_guid)],
313                              short_name))
314                     for r in rep.rep_repsTo:
315                         edgelists['to'].append(
316                             (guid_to_dnstr[str(r.source_dsa_obj_guid)],
317                              dsa_dn,
318                              short_name))
319
320         # Here we have the set of edges. From now it is a matter of
321         # interpretation and presentation.
322
323         if self.calc_output_format(format, output) == 'distance':
324             color_scheme = self.calc_distance_color_scheme(color,
325                                                            color_scheme,
326                                                            output)
327             header_strings = {
328                 'from': "RepsFrom objects for %s",
329                 'to': "RepsTo objects for %s",
330             }
331             for state, edgelists in all_edges.items():
332                 for direction, items in edgelists.items():
333                     part_edges = defaultdict(list)
334                     for src, dest, part in items:
335                         part_edges[part].append((src, dest))
336                     for part, edges in part_edges.items():
337                         s = distance_matrix(None, edges,
338                                             utf8=utf8,
339                                             colour=color_scheme,
340                                             shorten_names=shorten_names,
341                                             generate_key=key,
342                                             grouping_function=get_dnstr_site)
343
344                         s = "\n%s\n%s" % (header_strings[direction] % part, s)
345                         self.write(s, output)
346             return
347
348         edge_colours = []
349         edge_styles = []
350         dot_edges = []
351         dot_vertices = set()
352         used_colours = {}
353         key_set = set()
354         for state, edgelist in all_edges.items():
355             for direction, items in edgelist.items():
356                 for src, dest, part in items:
357                     colour = used_colours.setdefault((part),
358                                                      colour_hash((part,
359                                                                   direction)))
360                     linestyle = 'dotted' if state == 'needed' else 'solid'
361                     arrow = 'open' if direction == 'to' else 'empty'
362                     dot_vertices.add(src)
363                     dot_vertices.add(dest)
364                     dot_edges.append((src, dest))
365                     edge_colours.append(colour)
366                     style = 'style="%s"; arrowhead=%s' % (linestyle, arrow)
367                     edge_styles.append(style)
368                     key_set.add((part, 'reps' + direction.title(),
369                                  colour, style))
370
371         key_items = []
372         if key:
373             for part, direction, colour, linestyle in sorted(key_set):
374                 key_items.append((False,
375                                   'color="%s"; %s' % (colour, linestyle),
376                                   "%s %s" % (part, direction)))
377             key_items.append((False,
378                               'style="dotted"; arrowhead="open"',
379                               "repsFromTo is needed"))
380             key_items.append((False,
381                               'style="solid"; arrowhead="open"',
382                               "repsFromTo currently exists"))
383
384         s = dot_graph(dot_vertices, dot_edges,
385                       directed=True,
386                       edge_colors=edge_colours,
387                       edge_styles=edge_styles,
388                       shorten_names=shorten_names,
389                       key_items=key_items)
390
391         if format == 'xdot':
392             self.call_xdot(s, output)
393         else:
394             self.write(s, output)
395
396
397 class NTDSConn(object):
398     """Collects observation counts for NTDS connections, so we know
399     whether all DSAs agree."""
400     def __init__(self, src, dest):
401         self.observations = 0
402         self.src_attests = False
403         self.dest_attests = False
404         self.src = src
405         self.dest = dest
406
407     def attest(self, attester):
408         self.observations += 1
409         if attester == self.src:
410             self.src_attests = True
411         if attester == self.dest:
412             self.dest_attests = True
413
414
415 class cmd_ntdsconn(GraphCommand):
416     "Draw the NTDSConnection graph"
417     takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
418         Option("--importldif", help="graph from samba_kcc generated ldif",
419                default=None),
420     ]
421
422     def import_ldif_db(self, ldif, lp):
423         d = tempfile.mkdtemp(prefix='samba-tool-visualise')
424         fn = os.path.join(d, 'imported.ldb')
425         self._tmp_fn_to_delete = fn
426         samdb = ldif_import_export.ldif_to_samdb(fn, lp, ldif)
427         return fn
428
429     def run(self, H=None, output=None, shorten_names=False,
430             key=True, talk_to_remote=False,
431             sambaopts=None, credopts=None, versionopts=None,
432             color=None, color_scheme=None,
433             utf8=None, format=None, importldif=None,
434             xdot=False):
435
436         lp = sambaopts.get_loadparm()
437         if importldif is None:
438             creds = credopts.get_credentials(lp, fallback_machine=True)
439         else:
440             creds = None
441             H = self.import_ldif_db(importldif, lp)
442
443         local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
444         local_dsa_dn = local_kcc.my_dsa_dnstr.split(',', 1)[1]
445         vertices = set()
446         attested_edges = []
447         for dsa_dn in dsas:
448             if talk_to_remote:
449                 res = local_kcc.samdb.search(dsa_dn,
450                                              scope=SCOPE_BASE,
451                                              attrs=["dNSHostName"])
452                 dns_name = res[0]["dNSHostName"][0]
453                 try:
454                     samdb = self.get_db("ldap://%s" % dns_name, sambaopts,
455                                         credopts)
456                 except LdbError as e:
457                     print("Could not contact ldap://%s (%s)" % (dns_name, e),
458                           file=sys.stderr)
459                     continue
460
461                 ntds_dn = samdb.get_dsServiceName()
462                 dn = samdb.domain_dn()
463             else:
464                 samdb = self.get_db(H, sambaopts, credopts)
465                 ntds_dn = 'CN=NTDS Settings,' + dsa_dn
466                 dn = dsa_dn
467
468             res = samdb.search(ntds_dn,
469                                scope=SCOPE_BASE,
470                                attrs=["msDS-isRODC"])
471
472             is_rodc = res[0]["msDS-isRODC"][0] == 'TRUE'
473
474             vertices.add((ntds_dn, 'RODC' if is_rodc else ''))
475             # XXX we could also look at schedule
476             res = samdb.search(dn,
477                                scope=SCOPE_SUBTREE,
478                                expression="(objectClass=nTDSConnection)",
479                                attrs=['fromServer'],
480                                # XXX can't be critical for ldif test
481                                # controls=["search_options:1:2"],
482                                controls=["search_options:0:2"],
483                                )
484
485             for msg in res:
486                 msgdn = str(msg.dn)
487                 dest_dn = msgdn[msgdn.index(',') + 1:]
488                 attested_edges.append((str(msg['fromServer'][0]),
489                                        dest_dn, ntds_dn))
490
491         if importldif and H == self._tmp_fn_to_delete:
492             os.remove(H)
493             os.rmdir(os.path.dirname(H))
494
495         # now we overlay all the graphs and generate styles accordingly
496         edges = {}
497         for src, dest, attester in attested_edges:
498             k = (src, dest)
499             if k in edges:
500                 e = edges[k]
501             else:
502                 e = NTDSConn(*k)
503                 edges[k] = e
504             e.attest(attester)
505
506         vertices, rodc_status = zip(*sorted(vertices))
507
508         if self.calc_output_format(format, output) == 'distance':
509             color_scheme = self.calc_distance_color_scheme(color,
510                                                            color_scheme,
511                                                            output)
512             colours = COLOUR_SETS[color_scheme]
513             c_header = colours.get('header', '')
514             c_reset = colours.get('reset', '')
515
516             epilog = []
517             if 'RODC' in rodc_status:
518                 epilog.append('No outbound connections are expected from RODCs')
519
520             if not talk_to_remote:
521                 # If we are not talking to remote servers, we list all
522                 # the connections.
523                 graph_edges = edges.keys()
524                 title = 'NTDS Connections known to %s' % local_dsa_dn
525
526             else:
527                 # If we are talking to the remotes, there are
528                 # interesting cases we can discover. What matters most
529                 # is that the destination (i.e. owner) knowns about
530                 # the connection, but it would be worth noting if the
531                 # source doesn't. Another strange situation could be
532                 # when a DC thinks there is a connection elsewhere,
533                 # but the computers allegedly involved don't believe
534                 # it exists.
535                 #
536                 # With limited bandwidth in the table, we mark the
537                 # edges known to the destination, and note the other
538                 # cases in a list after the diagram.
539                 graph_edges = []
540                 source_denies = []
541                 dest_denies = []
542                 both_deny = []
543                 for e, conn in edges.items():
544                     if conn.dest_attests:
545                         graph_edges.append(e)
546                         if not conn.src_attests:
547                             source_denies.append(e)
548                     elif conn.src_attests:
549                         dest_denies.append(e)
550                     else:
551                         both_deny.append(e)
552
553                 title = 'NTDS Connections known to each destination DC'
554
555                 if both_deny:
556                     epilog.append('The following connections are alleged by '
557                                   'DCs other than the source and '
558                                   'destination:\n')
559                     for e in both_deny:
560                         epilog.append('  %s -> %s\n' % e)
561                 if dest_denies:
562                     epilog.append('The following connections are alleged by '
563                                   'DCs other than the destination but '
564                                   'including the source:\n')
565                     for e in dest_denies:
566                         epilog.append('  %s -> %s\n' % e)
567                 if source_denies:
568                     epilog.append('The following connections '
569                                   '(included in the chart) '
570                                   'are not known to the source DC:\n')
571                     for e in source_denies:
572                         epilog.append('  %s -> %s\n' % e)
573
574             s = distance_matrix(vertices, graph_edges,
575                                 utf8=utf8,
576                                 colour=color_scheme,
577                                 shorten_names=shorten_names,
578                                 generate_key=key,
579                                 grouping_function=get_dnstrlist_site,
580                                 row_comments=rodc_status)
581
582             epilog = ''.join(epilog)
583             if epilog:
584                 epilog = '\n%sNOTES%s\n%s' % (c_header,
585                                               c_reset,
586                                               epilog)
587
588             self.write('\n%s\n\n%s\n%s' % (title,
589                                            s,
590                                            epilog), output)
591             return
592
593         dot_edges = []
594         edge_colours = []
595         edge_styles = []
596         edge_labels = []
597         n_servers = len(dsas)
598         for k, e in sorted(edges.items()):
599             dot_edges.append(k)
600             if e.observations == n_servers or not talk_to_remote:
601                 edge_colours.append('#000000')
602                 edge_styles.append('')
603             elif e.dest_attests:
604                 edge_styles.append('')
605                 if e.src_attests:
606                     edge_colours.append('#0000ff')
607                 else:
608                     edge_colours.append('#cc00ff')
609             elif e.src_attests:
610                 edge_colours.append('#ff0000')
611                 edge_styles.append('style=dashed')
612             else:
613                 edge_colours.append('#ff0000')
614                 edge_styles.append('style=dotted')
615
616         key_items = []
617         if key:
618             key_items.append((False,
619                               'color="#000000"',
620                               "NTDS Connection"))
621             for colour, desc in (('#0000ff', "missing from some DCs"),
622                                  ('#cc00ff', "missing from source DC")):
623                 if colour in edge_colours:
624                     key_items.append((False, 'color="%s"' % colour, desc))
625
626             for style, desc in (('style=dashed', "unknown to destination"),
627                                 ('style=dotted',
628                                  "unknown to source and destination")):
629                 if style in edge_styles:
630                     key_items.append((False,
631                                       'color="#ff0000; %s"' % style,
632                                       desc))
633
634         if talk_to_remote:
635             title = 'NTDS Connections'
636         else:
637             title = 'NTDS Connections known to %s' % local_dsa_dn
638
639         s = dot_graph(sorted(vertices), dot_edges,
640                       directed=True,
641                       title=title,
642                       edge_colors=edge_colours,
643                       edge_labels=edge_labels,
644                       edge_styles=edge_styles,
645                       shorten_names=shorten_names,
646                       key_items=key_items)
647
648         if format == 'xdot':
649             self.call_xdot(s, output)
650         else:
651             self.write(s, output)
652
653
654 class cmd_uptodateness(GraphCommand):
655     """visualize uptodateness vectors"""
656
657     takes_options = COMMON_OPTIONS + [
658         Option("-p", "--partition", help="restrict to this partition",
659                default=None),
660         Option("--max-digits", default=3, type=int,
661                help="display this many digits of out-of-date-ness"),
662     ]
663
664     def run(self, H=None, output=None, shorten_names=False,
665             key=True, talk_to_remote=False,
666             sambaopts=None, credopts=None, versionopts=None,
667             color=None, color_scheme=None,
668             utf8=False, format=None, importldif=None,
669             xdot=False, partition=None, max_digits=3):
670         if not talk_to_remote:
671             print("this won't work without talking to the remote servers "
672                   "(use -r)", file=self.outf)
673             return
674
675         # We use the KCC libraries in readonly mode to get the
676         # replication graph.
677         lp = sambaopts.get_loadparm()
678         creds = credopts.get_credentials(lp, fallback_machine=True)
679         local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
680         self.samdb = local_kcc.samdb
681         partition = get_partition(self.samdb, partition)
682
683         short_partitions, long_partitions = get_partition_maps(self.samdb)
684         color_scheme = self.calc_distance_color_scheme(color,
685                                                        color_scheme,
686                                                        output)
687
688         for part_name, part_dn in short_partitions.items():
689             if partition not in (part_dn, None):
690                 continue  # we aren't doing this partition
691
692             utdv_edges = get_utdv_edges(local_kcc, dsas, part_dn, lp, creds)
693
694             distances = {}
695             max_distance = 0
696             for dn1 in dsas:
697                 try:
698                     peak = utdv_edges[dn1][dn1]
699                 except KeyError as e:
700                     peak = 0
701                 d = {}
702                 distances[dn1] = d
703                 for dn2 in dsas:
704                     if dn2 in utdv_edges:
705                         if dn1 in utdv_edges[dn2]:
706                             dist = peak - utdv_edges[dn2][dn1]
707                             d[dn2] = dist
708                             if dist > max_distance:
709                                 max_distance = dist
710                         else:
711                             print("Missing dn %s from UTD vector" % dn1,
712                                   file=sys.stderr)
713                     else:
714                         print("missing dn %s from UTD vector list" % dn2,
715                               file=sys.stderr)
716
717             digits = min(max_digits, len(str(max_distance)))
718             if digits < 1:
719                 digits = 1
720             c_scale = 10 ** digits
721
722             s = full_matrix(distances,
723                             utf8=utf8,
724                             colour=color_scheme,
725                             shorten_names=shorten_names,
726                             generate_key=key,
727                             grouping_function=get_dnstr_site,
728                             colour_scale=c_scale,
729                             digits=digits,
730                             ylabel='DC',
731                             xlabel='out-of-date-ness')
732
733             self.write('\n%s\n\n%s' % (part_name, s), output)
734
735
736 class cmd_visualize(SuperCommand):
737     """Produces graphical representations of Samba network state"""
738     subcommands = {}
739
740     for k, v in globals().items():
741         if k.startswith('cmd_'):
742             subcommands[k[4:]] = v()