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