[PATCH] samba-tool visualize uptodateness

Douglas Bagnall douglas.bagnall at catalyst.net.nz
Sun Jun 10 12:13:43 UTC 2018


On 10/06/18 16:28, Andrew Bartlett via samba-technical wrote:
> On Sun, 2018-06-10 at 09:02 +1200, Douglas Bagnall wrote:
>> Thanks Andrew. Pushed.
>>
>> This is what I squashed in:
>>
>> --- a/python/samba/netcmd/visualize.py
>> +++ b/python/samba/netcmd/visualize.py
>> @@ -687,11 +687,12 @@ class cmd_uptodateness(GraphCommand):
>>      def get_utdv(self, samdb, dn):
>>          """This finds the uptodateness vector in the database."""
>>          cursors = []
>> +        config_dn = samdb.get_config_basedn()
>>          for c in dsdb._dsdb_load_udv_v2(samdb, dn):
>>              inv_id = str(c.source_dsa_invocation_id)
>> -            res = samdb.search(expression=("(&(invocationId=%s)"
>> +            res = samdb.search(base=config_dn,
>> +                               expression=("(&(invocationId=%s)"
>>                                             "(objectClass=nTDSDSA))" % inv_id),
>> -                               #scope=SCOPE_SUBTREE,
>>                                 controls=["search_options:1:2"],
>>                                 attrs=["distinguishedName", "invocationId"])
> 
> Thanks.  While it now won't do anything, I think you can now remove
> the 
> 
> controls=["search_options:1:2"]
> 

Well, thanks to the miracle of autobuild failures, I can put that in too.

Though actually the failure was not a miracle, just righteous vengence
upon my patch which didn't handle RODCs very nicely (RODCs don't
really participate in uptodateness calculations). One of the other
tests leaves RODCs around. The short version is

make test TESTS='repl_rodc visualize_drs'

fails. Looking into this also revealed a source of flappiness, and I
noticed something that would break in Python 3.

The new version removes the search options control and has the following changes
squashed into patches 06 and 11.

Don't crash on RODCs:

--- a/python/samba/netcmd/visualize.py
+++ b/python/samba/netcmd/visualize.py
@@ -770,7 +770,10 @@ class cmd_uptodateness(GraphCommand):
             distances = {}
             max_distance = 0
             for dn1 in dsas:
-                peak = utdv_edges[dn1][dn1]
+                try:
+                    peak = utdv_edges[dn1][dn1]
+                except KeyError as e:
+                    peak = 0
                 d = {}
                 distances[dn1] = d
                 for dn2 in dsas:

Don't make racy assertions:

--- a/python/samba/tests/samba_tool/visualize_drs.py
+++ b/python/samba/tests/samba_tool/visualize_drs.py
@@ -400,7 +400,8 @@ class SambaToolVisualizeDrsTest(SambaToolCmdTest):
         self.assertCmdSuccess(result, out, err)
         matrix = get_utf8_matrix(out)
         self.assert_matrix_validity(matrix, [dc1, dc2])
-        # we can't assert much
+        # We can't assert actual values after this because
+        # auto-replication is on and things will change underneath us.
 
         (result, out, err) = self.runsubcmd("visualize", "uptodateness",
                                             "-r",
@@ -428,7 +429,6 @@ class SambaToolVisualizeDrsTest(SambaToolCmdTest):
         self.assertCmdSuccess(result, out, err)
         matrix = get_utf8_matrix(out)
         self.assert_matrix_validity(matrix, [dc1, dc2])
-        self.assertEqual(matrix[dc2][dc1], 0)
 
         force_replication(dc2, dc1, samdb2.domain_dn())
         (result, out, err) = self.runsubcmd("visualize", "uptodateness",
@@ -442,7 +442,6 @@ class SambaToolVisualizeDrsTest(SambaToolCmdTest):
         self.assertCmdSuccess(result, out, err)
         matrix = get_utf8_matrix(out)
         self.assert_matrix_validity(matrix, [dc1, dc2])
-        self.assertEqual(matrix[dc1][dc2], 0)
 
         (result, out, err) = self.runsubcmd("visualize", "uptodateness",
                                             "-r",

py3 .keys() is not a list:

--- a/python/samba/graph.py
+++ b/python/samba/graph.py
@@ -709,7 +709,7 @@ def full_matrix(rows,
     colours = COLOUR_SETS[colour]
 
     colour_cycle = cycle(colours.get('alternate rows', ('',)))
-    vertices = rows.keys()
+    vertices = list(rows.keys())
     if grouping_function is not None:
         # we sort and colour according to the grouping function
         # which can be used to e.g. alternate colours by site.
@@ -729,7 +729,7 @@ def full_matrix(rows,
             rows2[vmap[vert]] = dict((vmap[k], v) for k, v in r.items())
 
         rows = rows2
-        vertices = rows.keys()
+        vertices = list(rows.keys())
 
     vlen = max(6, len(xlabel), max(len(v) for v in vertices))
 

cheers,
Douglas
-------------- next part --------------
From 327549c8eb4dbb1808ba178adba1268c6fb2345f Mon Sep 17 00:00:00 2001
From: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
Date: Thu, 24 May 2018 14:42:37 +1200
Subject: [PATCH 01/13] python/graph: tweak colour schemes for distance charts

This works a bit better in terminals with white text.

Signed-off-by: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
---
 python/samba/graph.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/python/samba/graph.py b/python/samba/graph.py
index a36dc25c2f7..385e377906b 100644
--- a/python/samba/graph.py
+++ b/python/samba/graph.py
@@ -404,7 +404,7 @@ COLOUR_SETS = {
         'connected': colour.xterm_256_colour(112),
         'transitive': colour.xterm_256_colour(214),
         'transitive scale': (colour.xterm_256_colour(190),
-                             colour.xterm_256_colour(226),
+                             colour.xterm_256_colour(184),
                              colour.xterm_256_colour(220),
                              colour.xterm_256_colour(214),
                              colour.xterm_256_colour(208),
@@ -421,7 +421,7 @@ COLOUR_SETS = {
         'connected': colour.xterm_256_colour(112, bg=True),
         'transitive': colour.xterm_256_colour(214, bg=True),
         'transitive scale': (colour.xterm_256_colour(190, bg=True),
-                             colour.xterm_256_colour(226, bg=True),
+                             colour.xterm_256_colour(184, bg=True),
                              colour.xterm_256_colour(220, bg=True),
                              colour.xterm_256_colour(214, bg=True),
                              colour.xterm_256_colour(208, bg=True),
-- 
2.11.0


From 25c259ca9f3564ba3bce949e351eccb9c571c8eb Mon Sep 17 00:00:00 2001
From: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
Date: Thu, 29 Mar 2018 15:52:25 +1300
Subject: [PATCH 02/13] samba-tool visualise: helper for getting the partition

Repeated code becomes a function.

Signed-off-by: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
---
 python/samba/netcmd/visualize.py | 21 ++++++++++++++-------
 1 file changed, 14 insertions(+), 7 deletions(-)

diff --git a/python/samba/netcmd/visualize.py b/python/samba/netcmd/visualize.py
index c9bc8244df6..193c543210d 100644
--- a/python/samba/netcmd/visualize.py
+++ b/python/samba/netcmd/visualize.py
@@ -215,6 +215,17 @@ def get_partition_maps(samdb):
     return short_to_long, long_to_short
 
 
+def get_partition(samdb, part):
+    # Allow people to say "--partition=DOMAIN" rather than
+    # "--partition=DC=blah,DC=..."
+    if part is not None:
+        short_partitions, long_partitions = get_partition_maps(samdb)
+        part = short_partitions.get(part.upper(), part)
+        if part not in long_partitions:
+            raise CommandError("unknown partition %s" % partition)
+    return part
+
+
 class cmd_reps(GraphCommand):
     "repsFrom/repsTo from every DSA"
 
@@ -235,13 +246,7 @@ class cmd_reps(GraphCommand):
         local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
         unix_now = local_kcc.unix_now
 
-        # Allow people to say "--partition=DOMAIN" rather than
-        # "--partition=DC=blah,DC=..."
-        short_partitions, long_partitions = get_partition_maps(local_kcc.samdb)
-        if partition is not None:
-            partition = short_partitions.get(partition.upper(), partition)
-            if partition not in long_partitions:
-                raise CommandError("unknown partition %s" % partition)
+        partition = get_partition(local_kcc.samdb, partition)
 
         # nc_reps is an autovivifying dictionary of dictionaries of lists.
         # nc_reps[partition]['current' | 'needed'] is a list of
@@ -307,6 +312,8 @@ class cmd_reps(GraphCommand):
         all_edges = {'needed':  {'to': [], 'from': []},
                      'current': {'to': [], 'from': []}}
 
+        short_partitions, long_partitions = get_partition_maps(local_kcc.samdb)
+
         for partname, part in nc_reps.items():
             for state, edgelists in all_edges.items():
                 for dsa_dn, rep in part[state]:
-- 
2.11.0


From 39b32e978219c5cfa6748b10a6c0effee93ba059 Mon Sep 17 00:00:00 2001
From: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
Date: Thu, 24 May 2018 15:22:47 +1200
Subject: [PATCH 03/13] samba-tool visualize: separate dot options from common
 options

because not all sub-commands make dot format

Signed-off-by: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
---
 python/samba/netcmd/visualize.py | 17 ++++++++++-------
 1 file changed, 10 insertions(+), 7 deletions(-)

diff --git a/python/samba/netcmd/visualize.py b/python/samba/netcmd/visualize.py
index 193c543210d..efae28ea086 100644
--- a/python/samba/netcmd/visualize.py
+++ b/python/samba/netcmd/visualize.py
@@ -43,10 +43,6 @@ COMMON_OPTIONS = [
            type=str, metavar="URL", dest="H"),
     Option("-o", "--output", help="write here (default stdout)",
            type=str, metavar="FILE", default=None),
-    Option("--dot", help="Graphviz dot output", dest='format',
-           const='dot', action='store_const'),
-    Option("--xdot", help="attempt to call Graphviz xdot", dest='format',
-           const='xdot', action='store_const'),
     Option("--distance", help="Distance matrix graph output (default)",
            dest='format', const='distance', action='store_const'),
     Option("--utf8", help="Use utf-8 Unicode characters",
@@ -65,6 +61,13 @@ COMMON_OPTIONS = [
            action='store_false', default=True, dest='key'),
 ]
 
+DOT_OPTIONS = [
+    Option("--dot", help="Graphviz dot output", dest='format',
+           const='dot', action='store_const'),
+    Option("--xdot", help="attempt to call Graphviz xdot", dest='format',
+           const='xdot', action='store_const'),
+]
+
 TEMP_FILE = '__temp__'
 
 
@@ -77,7 +80,7 @@ class GraphCommand(Command):
         "versionopts": options.VersionOptions,
         "credopts": options.CredentialsOptions,
     }
-    takes_options = COMMON_OPTIONS
+    takes_options = COMMON_OPTIONS + DOT_OPTIONS
     takes_args = ()
 
     def get_db(self, H, sambaopts, credopts):
@@ -229,7 +232,7 @@ def get_partition(samdb, part):
 class cmd_reps(GraphCommand):
     "repsFrom/repsTo from every DSA"
 
-    takes_options = COMMON_OPTIONS + [
+    takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
         Option("-p", "--partition", help="restrict to this partition",
                default=None),
     ]
@@ -426,7 +429,7 @@ class NTDSConn(object):
 
 class cmd_ntdsconn(GraphCommand):
     "Draw the NTDSConnection graph"
-    takes_options = COMMON_OPTIONS + [
+    takes_options = COMMON_OPTIONS + DOT_OPTIONS + [
         Option("--importldif", help="graph from samba_kcc generated ldif",
                default=None),
     ]
-- 
2.11.0


From 12df3f4b66d779afb8cfe6c0dce4d8806b79cbac Mon Sep 17 00:00:00 2001
From: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
Date: Tue, 15 May 2018 22:26:43 +1200
Subject: [PATCH 04/13] python/samba/graph: use look up table for ascii-art
 charsets

Signed-off-by: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
---
 python/samba/graph.py | 40 ++++++++++++++++++++++++++++------------
 1 file changed, 28 insertions(+), 12 deletions(-)

diff --git a/python/samba/graph.py b/python/samba/graph.py
index 385e377906b..53515a32710 100644
--- a/python/samba/graph.py
+++ b/python/samba/graph.py
@@ -439,6 +439,27 @@ COLOUR_SETS = {
     }
 }
 
+CHARSETS = {
+    'utf8': {
+        'vertical': '│',
+        'horizontal': '─',
+        'corner': '╭',
+        #'diagonal': '╲',
+        'diagonal': '·',
+        #'missing': '🕱',
+        'missing': '-',
+        'right_arrow': '←',
+    },
+    'ascii': {
+        'vertical': '|',
+        'horizontal': '-',
+        'corner': ',',
+        'diagonal': '0',
+        'missing': '-',
+        'right_arrow': '<-',
+    }
+}
+
 
 def find_transitive_distance(vertices, edges):
     all_vertices = (set(vertices) |
@@ -518,18 +539,13 @@ def distance_matrix(vertices, edges,
     lines = []
     write = lines.append
 
-    if utf8:
-        vertical = '│'
-        horizontal = '─'
-        corner = '╭'
-        #diagonal = '╲'
-        diagonal = '·'
-        #missing = '🕱'
-        missing = '-'
-        right_arrow = '←'
-    else:
-        vertical, horizontal, corner, diagonal, missing = '|-,0-'
-        right_arrow = '<-'
+    charset = CHARSETS['utf8' if utf8 else 'ascii']
+    vertical = charset['vertical']
+    horizontal = charset['horizontal']
+    corner = charset['corner']
+    diagonal = charset['diagonal']
+    missing = charset['missing']
+    right_arrow = charset['right_arrow']
 
     colours = COLOUR_SETS[colour]
 
-- 
2.11.0


From 3a8c49e3418ad7ebdeeb775246c83d817eebac88 Mon Sep 17 00:00:00 2001
From: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
Date: Fri, 1 Jun 2018 16:39:19 +1200
Subject: [PATCH 05/13] python/graph: rework shorten_vertex_names to not need
 edges

This will be necessary for the forthcoming full_matrix function.

Signed-off-by: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
---
 python/samba/graph.py | 123 +++++++++++++++++++++++++-------------------------
 1 file changed, 62 insertions(+), 61 deletions(-)

diff --git a/python/samba/graph.py b/python/samba/graph.py
index 53515a32710..646600f2b2c 100644
--- a/python/samba/graph.py
+++ b/python/samba/graph.py
@@ -61,91 +61,82 @@ def quote_graph_label(s, reformat=False):
     return "%s" % s
 
 
-def shorten_vertex_names(edges, vertices, suffix=',...', aggressive=False):
+def shorten_vertex_names(vertices, suffix=',...', aggressive=False):
     """Replace the common suffix (in practice, the base DN) of a number of
     vertices with a short string (default ",..."). If this seems
     pointless because the replaced string is very short or the results
     seem strange, the original vertices are retained.
 
-    :param edges: a sequence of vertex pairs to shorten
     :param vertices: a sequence of vertices to shorten
     :param suffix: the replacement string [",..."]
+    :param aggressive: replace certain common non-suffix strings
 
-    :return: tuple of (edges, vertices, replacement)
+    :return: tuple of (rename map, replacements)
 
-    If no change is made, the returned edges and vertices will be the
-    original lists  and replacement will be None.
-
-    If a change is made, replacement will be a tuple (new, original)
-    indicating the new suffix that replaces the old.
+    The rename map is a dictionary mapping the old vertex names to
+    their shortened versions. If no changes are made, replacements
+    will be empty.
     """
-    vlist = list(set(x[0] for x in edges) |
-                 set(x[1] for x in edges) |
-                 set(vertices))
-
-    if len(vlist) < 2:
-        return edges, vertices, None
-
-    # walk backwards along all the strings until we meet a character
-    # that is not shared by all.
-    i = -1
-    try:
-        while True:
-            c = set(x[i] for x in vlist)
-            if len(c) > 1:
-                break
-            i -= 1
-    except IndexError:
-        # We have indexed beyond the start of a string, which should
-        # only happen if one node is a strict suffix of all others.
-        return edges, vertices, None
-
-    # add one to get to the last unanimous character.
-    i += 1
-
-    # now, we actually really want to split on a comma. So we walk
-    # back to a comma.
-    x = vlist[0]
-    while i < len(x) and x[i] != ',':
+    vmap = dict((v, v) for v in vertices)
+    replacements = []
+
+    if len(vmap) > 1:
+        # walk backwards along all the strings until we meet a character
+        # that is not shared by all.
+        i = -1
+        vlist = vmap.values()
+        try:
+            while True:
+                c = set(x[i] for x in vlist)
+                if len(c) > 1 or c == {'*'}:
+                    break
+                i -= 1
+        except IndexError:
+            # We have indexed beyond the start of a string, which should
+            # only happen if one node is a strict suffix of all others.
+            return vmap, replacements
+
+        # add one to get to the last unanimous character.
         i += 1
 
-    if i >= -len(suffix):
-        # there is nothing to gain here
-        return edges, vertices, None
+        # now, we actually really want to split on a comma. So we walk
+        # back to a comma.
+        x = vlist[0]
+        while i < len(x) and x[i] != ',':
+            i += 1
 
-    edges2 = []
-    vertices2 = []
+        if i >= -len(suffix):
+            # there is nothing to gain here
+            return vmap, replacements
 
-    for a, b in edges:
-        edges2.append((a[:i] + suffix, b[:i] + suffix))
-    for a in vertices:
-        vertices2.append(a[:i] + suffix)
+        replacements.append((suffix, x[i:]))
 
-    replacements = [(suffix, a[i:])]
+        for k, v in vmap.items():
+            vmap[k] = v[:i] + suffix
 
     if aggressive:
         # Remove known common annoying strings
-        map = dict((v, v) for v in vertices2)
-        for v in vertices2:
+        for v in vmap.values():
             if ',CN=Servers,' not in v:
                 break
         else:
-            map = dict((k, v.replace(',CN=Servers,', ',**,'))
-                       for k, v in map.items())
+            vmap = dict((k, v.replace(',CN=Servers,', ',**,', 1))
+                       for k, v in vmap.items())
             replacements.append(('**', 'CN=Servers'))
 
-        for v in vertices2:
+        for v in vmap.values():
             if not v.startswith('CN=NTDS Settings,'):
                 break
         else:
-            map = dict((k, v.replace('CN=NTDS Settings,', '*,'))
-                       for k, v in map.items())
+            vmap = dict((k, v.replace('CN=NTDS Settings,', '*,', 1))
+                       for k, v in vmap.items())
             replacements.append(('*', 'CN=NTDS Settings'))
 
-        edges2 = [(map.get(a, a), map.get(b, b)) for a, b in edges2]
-        vertices2 = [map.get(a, a) for a in vertices2]
+    return vmap, replacements
+
+
+
 
-    return edges2, vertices2, replacements
 
 
 def compile_graph_key(key_items, nodes_above=[], elisions=None,
@@ -292,7 +283,13 @@ def dot_graph(vertices, edges,
         vertices = set(x[0] for x in edges) | set(x[1] for x in edges)
 
     if shorten_names:
-        edges, vertices, elisions = shorten_vertex_names(edges, vertices)
+        vlist = list(set(x[0] for x in edges) |
+                     set(x[1] for x in edges) |
+                     set(vertices))
+        vmap, elisions = shorten_vertex_names(vlist)
+        vertices = [vmap[x] for x in vertices]
+        edges = [(vmap[a], vmap[b]) for a, b in edges]
+
     else:
         elisions = None
 
@@ -566,10 +563,14 @@ def distance_matrix(vertices, edges,
         colour_list = [next(colour_cycle) for v in vertices]
 
     if shorten_names:
-        edges, vertices, replacements = shorten_vertex_names(edges,
-                                                             vertices,
-                                                             '+',
-                                                             aggressive=True)
+        vlist = list(set(x[0] for x in edges) |
+                     set(x[1] for x in edges) |
+                     set(vertices))
+        vmap, replacements = shorten_vertex_names(vlist, '+',
+                                                  aggressive=True)
+        vertices = [vmap[x] for x in vertices]
+        edges = [(vmap[a], vmap[b]) for a, b in edges]
+
 
     vlen = max(6, max(len(v) for v in vertices))
 
-- 
2.11.0


From 297e3ad566db58e43c24029219f0b8f20e666482 Mon Sep 17 00:00:00 2001
From: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
Date: Fri, 1 Jun 2018 16:48:34 +1200
Subject: [PATCH 06/13] python/graph: add full_matrix graph function

This makes an ASCII/ANSI art picture like distance_matrix(), but from
a full matrix, not a list of adjacencies as in the distance_matrix case.

This will be used to visualise up-to-dateness vectors.

Signed-off-by: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
---
 python/samba/graph.py | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 162 insertions(+)

diff --git a/python/samba/graph.py b/python/samba/graph.py
index 646600f2b2c..305c53edc7f 100644
--- a/python/samba/graph.py
+++ b/python/samba/graph.py
@@ -658,3 +658,165 @@ def distance_matrix(vertices, edges,
               (c_disconn, missing, c_reset))
 
     return '\n'.join(lines)
+
+
+def pad_char(char, digits, padding=' '):
+    if digits == 1:
+        padding = ''
+    return ' ' * (digits - 1) + char + padding
+
+
+def transpose_dict_matrix(m):
+    m2 = {}
+    for k1, row in m.items():
+        for k2, dist in row.items():
+            m2.setdefault(k2, {})[k1] = dist
+    return m2
+
+def full_matrix(rows,
+                utf8=False,
+                colour=None,
+                shorten_names=False,
+                generate_key=False,
+                grouping_function=None,
+                row_comments=None,
+                colour_scale=None,
+                digits=1,
+                ylabel='source',
+                xlabel='destination',
+                transpose=True):
+    lines = []
+    write = lines.append
+
+    if transpose:
+        rows = transpose_dict_matrix(rows)
+
+    use_padding = digits > 1
+
+    charset = CHARSETS['utf8' if utf8 else 'ascii']
+    vertical = pad_char(charset['vertical'], digits)
+    horizontal = charset['horizontal'] * (digits + use_padding)
+    corner = pad_char(charset['corner'], digits,
+                      charset['horizontal'])
+    diagonal = pad_char(charset['diagonal'], digits)
+    missing = pad_char(charset['missing'], digits)
+    toobig = pad_char('>', digits)
+    right_arrow = charset['right_arrow']
+    empty = pad_char(' ', digits)
+
+    colours = COLOUR_SETS[colour]
+
+    colour_cycle = cycle(colours.get('alternate rows', ('',)))
+    vertices = list(rows.keys())
+    if grouping_function is not None:
+        # we sort and colour according to the grouping function
+        # which can be used to e.g. alternate colours by site.
+        vertices.sort(key=grouping_function)
+        colour_list = []
+        for k, v in groupby(vertices, key=grouping_function):
+            c = colour_cycle.next()
+            colour_list.extend(c for x in v)
+    else:
+        colour_list = [colour_cycle.next() for v in vertices]
+
+    if shorten_names:
+        vmap, replacements = shorten_vertex_names(vertices, '+',
+                                                  aggressive=True)
+        rows2 = {}
+        for vert, r in rows.items():
+            rows2[vmap[vert]] = dict((vmap[k], v) for k, v in r.items())
+
+        rows = rows2
+        vertices = list(rows.keys())
+
+    vlen = max(6, len(xlabel), max(len(v) for v in vertices))
+
+    # first, the key for the columns
+    c_header = colours.get('header', '')
+    c_disconn = colours.get('disconnected', '')
+    c_conn = colours.get('connected', '')
+    c_reset = colours.get('reset', '')
+
+    if colour_scale is None:
+        colour_scale = len(rows)
+    colour_transitive = get_transitive_colourer(colours, colour_scale)
+
+    vspace = ' ' * vlen
+    verticals = ''
+    write("%s %s %s%s%s" % (vspace,
+                            empty * (len(rows) + 1),
+                            c_header,
+                            xlabel,
+                            c_reset))
+    for i, v in enumerate(vertices):
+        j = len(rows) - i
+        c = colour_list[i]
+        if j == 1:
+            start = '%s%s%s%s' % (vspace[:-len(ylabel)],
+                                  c_header,
+                                  ylabel,
+                                  c_reset)
+        else:
+            start = vspace
+        write('%s %s%s%s%s%s %s%s' % (start,
+                                      verticals,
+                                      c_reset,
+                                      c,
+                                      corner,
+                                      horizontal * j,
+                                      v,
+                                      c_reset
+        ))
+        verticals += '%s%s' % (c, vertical)
+
+    end_cell = '%s%s' % (' ' * use_padding, c_reset)
+    overflow = False
+    for i, v in enumerate(vertices):
+        links = rows[v]
+        c = colour_list[i]
+        row = []
+        for v2 in vertices:
+            if v2 not in links:
+                row.append('%s%s%s' % (c_disconn, missing, c_reset))
+            elif v == v2:
+                row.append('%s%s%s%s' % (c_reset, c, diagonal, c_reset))
+            else:
+                link = links[v2]
+                if link >= 10 ** digits:
+                    ct = colour_transitive(link)
+                    row.append('%s%s%s' % (ct, toobig, c_reset))
+                    overflow = True
+                    continue
+                if link == 0:
+                    ct = c_conn
+                else:
+                    ct = colour_transitive(link)
+                row.append('%s%*s%s' % (ct, digits, link, end_cell))
+
+        if row_comments is not None and row_comments[i]:
+            row.append('%s %s %s' % (c_reset, right_arrow, row_comments[i]))
+
+        write('%s%*s%s %s%s' % (c, vlen, v, c_reset,
+                                ''.join(row), c_reset))
+
+    if overflow or shorten_names:
+        write('')
+
+    if overflow:
+            write("'%s%s%s' means greater than %d " %
+                  (colour_transitive(10 ** digits),
+                   toobig,
+                   c_reset,
+                   10 ** digits - 1))
+
+    if shorten_names:
+        example_c = colour_cycle.next()
+        for substitute, original in reversed(replacements):
+            write("'%s%s%s' stands for '%s%s%s'" % (example_c,
+                                                    substitute,
+                                                    c_reset,
+                                                    example_c,
+                                                    original,
+                                                    c_reset))
+
+    return '\n'.join(lines)
-- 
2.11.0


From 1f43df3f1daf9b1d578bd94b0ead4215162c03ac Mon Sep 17 00:00:00 2001
From: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
Date: Fri, 1 Jun 2018 16:51:19 +1200
Subject: [PATCH 07/13] python/graph: use '>' for excessive numbers, not '+'

'+' already has another meaning in these graphs.


Signed-off-by: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
---
 python/samba/graph.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/python/samba/graph.py b/python/samba/graph.py
index 305c53edc7f..17626e0a361 100644
--- a/python/samba/graph.py
+++ b/python/samba/graph.py
@@ -624,7 +624,7 @@ def distance_matrix(vertices, edges,
             else:
                 ct = colour_transitive(link)
                 if link > 9:
-                    link = '+'
+                    link = '>'
                 row.append('%s%s%s' % (ct, link, c_reset))
 
         if row_comments is not None and row_comments[i]:
-- 
2.11.0


From 13df703794cd4b08b9e8af43f2a7b1b86d4a07ec Mon Sep 17 00:00:00 2001
From: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
Date: Fri, 1 Jun 2018 16:55:37 +1200
Subject: [PATCH 08/13] python/graph: don't crash colourer on bad link

Signed-off-by: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
---
 python/samba/graph.py | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/python/samba/graph.py b/python/samba/graph.py
index 17626e0a361..0c389a74e3a 100644
--- a/python/samba/graph.py
+++ b/python/samba/graph.py
@@ -517,6 +517,8 @@ def get_transitive_colourer(colours, n_vertices):
         n = 1 + int(n_vertices ** 0.5)
 
         def f(link):
+            if not isinstance(link, int):
+                return ''
             return scale[min(link * m // n, m - 1)]
 
     else:
-- 
2.11.0


From 28b3a8da9f02508a94eed34a01592ccb0fb5ae3a Mon Sep 17 00:00:00 2001
From: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
Date: Wed, 7 Mar 2018 11:40:00 +1300
Subject: [PATCH 09/13] samba-tool visualize: fix wrong variable name in
 get_partition()

Signed-off-by: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
---
 python/samba/netcmd/visualize.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/python/samba/netcmd/visualize.py b/python/samba/netcmd/visualize.py
index efae28ea086..e8fff9407ed 100644
--- a/python/samba/netcmd/visualize.py
+++ b/python/samba/netcmd/visualize.py
@@ -225,7 +225,7 @@ def get_partition(samdb, part):
         short_partitions, long_partitions = get_partition_maps(samdb)
         part = short_partitions.get(part.upper(), part)
         if part not in long_partitions:
-            raise CommandError("unknown partition %s" % partition)
+            raise CommandError("unknown partition %s" % part)
     return part
 
 
-- 
2.11.0


From de8e80ba69d1873222d3d48728a4b89169243590 Mon Sep 17 00:00:00 2001
From: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
Date: Fri, 1 Jun 2018 17:14:32 +1200
Subject: [PATCH 10/13] samba-tool visualize ntdsconn: properly sort/group
 vertices

The vertex is now a tuple, with the RODC state added.

Signed-off-by: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
---
 python/samba/netcmd/visualize.py | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/python/samba/netcmd/visualize.py b/python/samba/netcmd/visualize.py
index e8fff9407ed..bfd7d3bf341 100644
--- a/python/samba/netcmd/visualize.py
+++ b/python/samba/netcmd/visualize.py
@@ -188,6 +188,12 @@ def get_dnstr_site(dn):
     return dn
 
 
+def get_dnstrlist_site(t):
+    """Helper function for sorting and grouping lists of (DN, ...) tuples
+    by site, if possible."""
+    return get_dnstr_site(t[0])
+
+
 def colour_hash(x):
     """Generate a randomish but consistent darkish colour based on the
     given object."""
@@ -586,13 +592,12 @@ class cmd_ntdsconn(GraphCommand):
                     for e in source_denies:
                         epilog.append('  %s -> %s\n' % e)
 
-
             s = distance_matrix(vertices, graph_edges,
                                 utf8=utf8,
                                 colour=color_scheme,
                                 shorten_names=shorten_names,
                                 generate_key=key,
-                                grouping_function=get_dnstr_site,
+                                grouping_function=get_dnstrlist_site,
                                 row_comments=rodc_status)
 
             epilog = ''.join(epilog)
-- 
2.11.0


From f75843365ef83f5ea7289cc3491486b4553d6bf5 Mon Sep 17 00:00:00 2001
From: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
Date: Fri, 1 Jun 2018 17:20:56 +1200
Subject: [PATCH 11/13] sambatool visualize: add up-to-dateness visualization

Or more accurately, out-of-dateness visualization, which shows how far
each DCs is from every other using the difference in the up-to-dateness
vectors.

An example usage is

samba-tool visualize uptodateness -r -S -H ldap://somewhere \
      -UAdministrator --color=auto --partition=DOMAIN

Signed-off-by: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
---
 python/samba/netcmd/visualize.py               | 138 ++++++++-
 python/samba/tests/samba_tool/visualize_drs.py | 398 ++++++++++++++++++++++++-
 2 files changed, 532 insertions(+), 4 deletions(-)

diff --git a/python/samba/netcmd/visualize.py b/python/samba/netcmd/visualize.py
index bfd7d3bf341..a24962ea58a 100644
--- a/python/samba/netcmd/visualize.py
+++ b/python/samba/netcmd/visualize.py
@@ -25,12 +25,14 @@ from collections import defaultdict
 import subprocess
 
 import tempfile
-import samba
 import samba.getopt as options
+from samba import dsdb
+from samba import nttime2unix
 from samba.netcmd import Command, SuperCommand, CommandError, Option
 from samba.samdb import SamDB
 from samba.graph import dot_graph
 from samba.graph import distance_matrix, COLOUR_SETS
+from samba.graph import full_matrix
 from ldb import SCOPE_BASE, SCOPE_SUBTREE, LdbError
 import time
 import re
@@ -672,6 +674,140 @@ class cmd_ntdsconn(GraphCommand):
             self.write(s, output)
 
 
+class cmd_uptodateness(GraphCommand):
+    """visualize uptodateness vectors"""
+
+    takes_options = COMMON_OPTIONS + [
+        Option("-p", "--partition", help="restrict to this partition",
+               default=None),
+        Option("--max-digits", default=3, type=int,
+               help="display this many digits of out-of-date-ness"),
+    ]
+
+    def get_utdv(self, samdb, dn):
+        """This finds the uptodateness vector in the database."""
+        cursors = []
+        config_dn = samdb.get_config_basedn()
+        for c in dsdb._dsdb_load_udv_v2(samdb, dn):
+            inv_id = str(c.source_dsa_invocation_id)
+            res = samdb.search(base=config_dn,
+                               expression=("(&(invocationId=%s)"
+                                           "(objectClass=nTDSDSA))" % inv_id),
+                               attrs=["distinguishedName", "invocationId"])
+            settings_dn = res[0]["distinguishedName"][0]
+            prefix, dsa_dn = settings_dn.split(',', 1)
+            if prefix != 'CN=NTDS Settings':
+                raise CommandError("Expected NTDS Settings DN, got %s" %
+                                   settings_dn)
+
+            cursors.append((dsa_dn,
+                            inv_id,
+                            int(c.highest_usn),
+                            nttime2unix(c.last_sync_success)))
+        return cursors
+
+    def get_own_cursor(self, samdb):
+            res = samdb.search(base="",
+                               scope=SCOPE_BASE,
+                               attrs=["highestCommittedUSN"])
+            usn = int(res[0]["highestCommittedUSN"][0])
+            now = int(time.time())
+            return (usn, now)
+
+    def run(self, H=None, output=None, shorten_names=False,
+            key=True, talk_to_remote=False,
+            sambaopts=None, credopts=None, versionopts=None,
+            color=None, color_scheme=None,
+            utf8=False, format=None, importldif=None,
+            xdot=False, partition=None, max_digits=3):
+        if not talk_to_remote:
+            print("this won't work without talking to the remote servers "
+                  "(use -r)", file=self.outf)
+            return
+
+        # We use the KCC libraries in readonly mode to get the
+        # replication graph.
+        lp = sambaopts.get_loadparm()
+        creds = credopts.get_credentials(lp, fallback_machine=True)
+        local_kcc, dsas = self.get_kcc_and_dsas(H, lp, creds)
+        self.samdb = local_kcc.samdb
+        partition = get_partition(self.samdb, partition)
+
+        short_partitions, long_partitions = get_partition_maps(self.samdb)
+        color_scheme = self.calc_distance_color_scheme(color,
+                                                       color_scheme,
+                                                       output)
+
+        for part_name, part_dn in short_partitions.items():
+            if partition not in (part_dn, None):
+                continue  # we aren't doing this partition
+
+            cursors = self.get_utdv(self.samdb, part_dn)
+
+            # we talk to each remote and make a matrix of the vectors
+            # -- for each partition
+            # normalise by oldest
+            utdv_edges = {}
+            for dsa_dn in dsas:
+                res = local_kcc.samdb.search(dsa_dn,
+                                             scope=SCOPE_BASE,
+                                             attrs=["dNSHostName"])
+                ldap_url = "ldap://%s" % res[0]["dNSHostName"][0]
+                try:
+                    samdb = self.get_db(ldap_url, sambaopts, credopts)
+                    cursors = self.get_utdv(samdb, part_dn)
+                    own_usn, own_time = self.get_own_cursor(samdb)
+                    remotes = {dsa_dn: own_usn}
+                    for dn, guid, usn, t in cursors:
+                        remotes[dn] = usn
+                except LdbError as e:
+                    print("Could not contact %s (%s)" % (ldap_url, e),
+                          file=sys.stderr)
+                    continue
+                utdv_edges[dsa_dn] = remotes
+
+            distances = {}
+            max_distance = 0
+            for dn1 in dsas:
+                try:
+                    peak = utdv_edges[dn1][dn1]
+                except KeyError as e:
+                    peak = 0
+                d = {}
+                distances[dn1] = d
+                for dn2 in dsas:
+                    if dn2 in utdv_edges:
+                        if dn1 in utdv_edges[dn2]:
+                            dist = peak - utdv_edges[dn2][dn1]
+                            d[dn2] = dist
+                            if dist > max_distance:
+                                max_distance = dist
+                        else:
+                            print("Missing dn %s from UTD vector" % dn1,
+                                  file=sys.stderr)
+                    else:
+                        print("missing dn %s from UTD vector list" % dn2,
+                              file=sys.stderr)
+
+            digits = min(max_digits, len(str(max_distance)))
+            if digits < 1:
+                digits = 1
+            c_scale = 10 ** digits
+
+            s = full_matrix(distances,
+                            utf8=utf8,
+                            colour=color_scheme,
+                            shorten_names=shorten_names,
+                            generate_key=key,
+                            grouping_function=get_dnstr_site,
+                            colour_scale=c_scale,
+                            digits=digits,
+                            ylabel='DC',
+                            xlabel='out-of-date-ness')
+
+            self.write('\n%s\n\n%s' % (part_name, s), output)
+
+
 class cmd_visualize(SuperCommand):
     """Produces graphical representations of Samba network state"""
     subcommands = {}
diff --git a/python/samba/tests/samba_tool/visualize_drs.py b/python/samba/tests/samba_tool/visualize_drs.py
index 7da0a4b1083..42facacd977 100644
--- a/python/samba/tests/samba_tool/visualize_drs.py
+++ b/python/samba/tests/samba_tool/visualize_drs.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
 # Originally based on tests for samba.kcc.ldif_import_export.
 # Copyright (C) Andrew Bartlett 2015, 2018
 #
@@ -16,15 +17,22 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #
-
 """Tests for samba-tool visualize using the vampire DC and promoted DC
-environments. We can't assert much about what state they are in, so we
-mainly check for cmmand failure.
+environments. For most tests we assume we can't assert much about what
+state they are in, so we mainly check for command failure, but for
+others we try to grasp control of replication and make more specific
+assertions.
 """
 
+from __future__ import print_function
 import os
+import re
+import random
+import subprocess
 from samba.tests.samba_tool.base import SambaToolCmdTest
 
+VERBOSE = False
+
 ENV_DSAS = {
     'promoted_dc': ['CN=PROMOTEDVDC,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com',
                     'CN=LOCALDC,CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com'],
@@ -33,6 +41,59 @@ ENV_DSAS = {
 }
 
 
+def set_auto_replication(dc, allow):
+    credstring = '-U%s%%%s' % (os.environ["USERNAME"], os.environ["PASSWORD"])
+    on_or_off = '-' if allow else '+'
+
+    for opt in ['DISABLE_INBOUND_REPL',
+                'DISABLE_OUTBOUND_REPL']:
+        cmd = ['bin/samba-tool',
+               'drs', 'options',
+               credstring, dc,
+               "--dsa-option=%s%s" % (on_or_off, opt)]
+
+        subprocess.check_call(cmd)
+
+
+def force_replication(src, dest, base):
+    credstring = '-U%s%%%s' % (os.environ["USERNAME"], os.environ["PASSWORD"])
+    cmd = ['bin/samba-tool',
+           'drs', 'replicate',
+           dest, src, base,
+           credstring,
+           '--sync-forced']
+
+    subprocess.check_call(cmd)
+
+
+def get_utf8_matrix(s):
+    # parse the graphical table *just* well enough for our tests
+    # decolourise first
+    s = re.sub("\033" r"\[[^m]+m", '', s)
+    lines = s.split('\n')
+    # matrix rows have '·' on the diagonal
+    rows = [x.strip().replace('·', '0') for x in lines if '·' in x]
+    names = []
+    values = []
+    for r in rows:
+        parts = r.rsplit(None, len(rows))
+        k, v = parts[0], parts[1:]
+        # we want the FOO in 'CN=FOO+' or 'CN=FOO,CN=x,DC=...'
+        k = re.match(r'cn=([^+,]+)', k.lower()).group(1)
+        names.append(k)
+        if len(v) == 1:  # this is a single-digit matrix, no spaces
+            v = list(v[0])
+        values.append([int(x) if x.isdigit() else 1e999 for x in v])
+
+    d = {}
+    for n1, row in zip(names, values):
+        d[n1] = {}
+        for n2, v in zip(names, row):
+            d[n1][n2] = v
+
+    return d
+
+
 class SambaToolVisualizeDrsTest(SambaToolCmdTest):
     def setUp(self):
         super(SambaToolVisualizeDrsTest, self).setUp()
@@ -64,6 +125,337 @@ class SambaToolVisualizeDrsTest(SambaToolCmdTest):
                                             '--color=no', '-S')
         self.assertCmdSuccess(result, out, err)
 
+    def test_uptodateness_all_partitions(self):
+        creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])
+        dc1 = os.environ["SERVER"]
+        dc2 = os.environ["DC_SERVER"]
+        # We will check that the visualisation works for the two
+        # stopped DCs, but we can't make assertions that the output
+        # will be the same because there may be replication between
+        # the two calls. Stopping the replication on these ones is not
+        # enough because there are other DCs about.
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=no', '-S')
+        self.assertCmdSuccess(result, out, err)
+
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc2,
+                                            '-U', creds,
+                                            '--color=no', '-S')
+        self.assertCmdSuccess(result, out, err)
+
+    def test_uptodateness_partitions(self):
+        creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])
+        dc1 = os.environ["SERVER"]
+        for part in ["CONFIGURATION",
+                     "SCHEMA",
+                     "DNSDOMAIN",
+                     "DNSFOREST"]:
+
+            (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                                "-r",
+                                                '-H', "ldap://%s" % dc1,
+                                                '-U', creds,
+                                                '--color=no', '-S',
+                                                '--partition', part)
+            self.assertCmdSuccess(result, out, err)
+
+    def assert_matrix_validity(self, matrix, dcs=()):
+        for dc in dcs:
+            self.assertIn(dc, matrix)
+        for k, row in matrix.items():
+            self.assertEqual(row[k], 0)
+
+    def test_uptodateness_stop_replication_domain(self):
+        creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])
+        dc1 = os.environ["SERVER"]
+        dc2 = os.environ["DC_SERVER"]
+        self.addCleanup(set_auto_replication, dc1, True)
+        self.addCleanup(set_auto_replication, dc2, True)
+
+        def display(heading, out):
+            if VERBOSE:
+                print("========", heading, "=========")
+                print(out)
+
+        samdb1 = self.getSamDB("-H", "ldap://%s" % dc1, "-U", creds)
+        samdb2 = self.getSamDB("-H", "ldap://%s" % dc2, "-U", creds)
+
+        domain_dn = samdb1.domain_dn()
+        self.assertTrue(domain_dn == samdb2.domain_dn(),
+                        "We expected the same domain_dn across DCs")
+
+        ou1 = "OU=dc1.%x,%s" % (random.randrange(1 << 64), domain_dn)
+        ou2 = "OU=dc2.%x,%s" % (random.randrange(1 << 64), domain_dn)
+        samdb1.add({
+            "dn": ou1,
+            "objectclass": "organizationalUnit"
+        })
+        samdb2.add({
+            "dn": ou2,
+            "objectclass": "organizationalUnit"
+        })
+
+        set_auto_replication(dc1, False)
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+        display("dc1 replication is now off", out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+
+        force_replication(dc2, dc1, domain_dn)
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+        display("forced replication %s -> %s" % (dc2, dc1), out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+        self.assertEqual(matrix[dc1][dc2], 0)
+
+        force_replication(dc1, dc2, domain_dn)
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+        display("forced replication %s -> %s" % (dc2, dc1), out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+        self.assertEqual(matrix[dc2][dc1], 0)
+
+        dn1 = 'cn=u1.%%d,%s' % (ou1)
+        dn2 = 'cn=u2.%%d,%s' % (ou2)
+
+        for i in range(10):
+            samdb1.add({
+                "dn": dn1 % i,
+                "objectclass": "user"
+            })
+
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+        display("added 10 users on %s" % dc1, out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+        # dc2's view of dc1 should now be 10 changes out of date
+        self.assertEqual(matrix[dc2][dc1], 10)
+
+        for i in range(10):
+            samdb2.add({
+                "dn": dn2 % i,
+                "objectclass": "user"
+            })
+
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+        display("added 10 users on %s" % dc2, out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+        # dc1's view of dc2 is probably 11 changes out of date
+        self.assertGreaterEqual(matrix[dc1][dc2], 10)
+
+        for i in range(10, 101):
+            samdb1.add({
+                "dn": dn1 % i,
+                "objectclass": "user"
+            })
+            samdb2.add({
+                "dn": dn2 % i,
+                "objectclass": "user"
+            })
+
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+        display("added 91 users on both", out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+        # the difference here should be ~101.
+        self.assertGreaterEqual(matrix[dc1][dc2], 100)
+        self.assertGreaterEqual(matrix[dc2][dc1], 100)
+
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN',
+                                            '--max-digits', '2')
+        display("with --max-digits 2", out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+        # visualising with 2 digits mean these overflow into infinity
+        self.assertGreaterEqual(matrix[dc1][dc2], 1e99)
+        self.assertGreaterEqual(matrix[dc2][dc1], 1e99)
+
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN',
+                                            '--max-digits', '1')
+        display("with --max-digits 1", out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+        # visualising with 1 digit means these overflow into infinity
+        self.assertGreaterEqual(matrix[dc1][dc2], 1e99)
+        self.assertGreaterEqual(matrix[dc2][dc1], 1e99)
+
+        force_replication(dc2, dc1, samdb1.domain_dn())
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+
+        display("forced replication %s -> %s" % (dc2, dc1), out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+        self.assertEqual(matrix[dc1][dc2], 0)
+
+        force_replication(dc1, dc2, samdb2.domain_dn())
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+
+        display("forced replication %s -> %s" % (dc1, dc2), out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+        self.assertEqual(matrix[dc2][dc1], 0)
+
+        samdb1.delete(ou1, ['tree_delete:1'])
+        samdb2.delete(ou2, ['tree_delete:1'])
+
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+        display("tree delete both ous on %s" % (dc1,), out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+        self.assertGreaterEqual(matrix[dc1][dc2], 100)
+        self.assertGreaterEqual(matrix[dc2][dc1], 100)
+
+        set_auto_replication(dc1, True)
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+        display("replication is now on", out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+        # We can't assert actual values after this because
+        # auto-replication is on and things will change underneath us.
+
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc2,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+
+        display("%s's view" % dc2, out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+
+        force_replication(dc1, dc2, samdb2.domain_dn())
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+
+        display("forced replication %s -> %s" % (dc1, dc2), out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+
+        force_replication(dc2, dc1, samdb2.domain_dn())
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc1,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+        display("forced replication %s -> %s" % (dc2, dc1), out)
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+
+        (result, out, err) = self.runsubcmd("visualize", "uptodateness",
+                                            "-r",
+                                            '-H', "ldap://%s" % dc2,
+                                            '-U', creds,
+                                            '--color=yes',
+                                            '--utf8', '-S',
+                                            '--partition', 'DOMAIN')
+        display("%s's view" % dc2, out)
+
+        self.assertCmdSuccess(result, out, err)
+        matrix = get_utf8_matrix(out)
+        self.assert_matrix_validity(matrix, [dc1, dc2])
+
     def test_reps_remote(self):
         server = "ldap://%s" % os.environ["SERVER"]
         creds = "%s%%%s" % (os.environ["USERNAME"], os.environ["PASSWORD"])
-- 
2.11.0


From ca76f68bea3aa7dfb3d902c703fa9e05691b4aa4 Mon Sep 17 00:00:00 2001
From: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
Date: Thu, 7 Jun 2018 20:11:26 +1200
Subject: [PATCH 12/13] python/join: fix a typo

Signed-off-by: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
---
 python/samba/join.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/python/samba/join.py b/python/samba/join.py
index dc6d234d0ed..30ecce77c55 100644
--- a/python/samba/join.py
+++ b/python/samba/join.py
@@ -239,7 +239,7 @@ class dc_join(object):
                    == res[0]["objectSID"][0]:
                     raise DCJoinException("Not removing account %s which "
                                        "looks like a Samba DC account "
-                                       "maching the password we already have.  "
+                                       "matching the password we already have.  "
                                        "To override, remove secrets.ldb and secrets.tdb"
                                     % ctx.samname)
 
-- 
2.11.0


From 6ffba56cae9d1af33ec9d35fc0bea35a4c02f51f Mon Sep 17 00:00:00 2001
From: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
Date: Fri, 8 Jun 2018 15:36:39 +1200
Subject: [PATCH 13/13] python/drs_utils: fix repeated typo

Signed-off-by: Douglas Bagnall <douglas.bagnall at catalyst.net.nz>
---
 python/samba/drs_utils.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/python/samba/drs_utils.py b/python/samba/drs_utils.py
index 1940d2d1b27..7fab4802522 100644
--- a/python/samba/drs_utils.py
+++ b/python/samba/drs_utils.py
@@ -65,7 +65,7 @@ def sendDsReplicaSync(drsuapiBind, drsuapi_handle, source_dsa_guid,
     """Send DS replica sync request.
 
     :param drsuapiBind: a drsuapi Bind object
-    :param drsuapi_handle: a drsuapi hanle on the drsuapi connection
+    :param drsuapi_handle: a drsuapi handle on the drsuapi connection
     :param source_dsa_guid: the guid of the source dsa for the replication
     :param naming_context: the DN of the naming context to replicate
     :param req_options: replication options for the DsReplicaSync call
@@ -91,7 +91,7 @@ def sendRemoveDsServer(drsuapiBind, drsuapi_handle, server_dsa_dn, domain):
     """Send RemoveDSServer request.
 
     :param drsuapiBind: a drsuapi Bind object
-    :param drsuapi_handle: a drsuapi hanle on the drsuapi connection
+    :param drsuapi_handle: a drsuapi handle on the drsuapi connection
     :param server_dsa_dn: a DN object of the server's dsa that we want to
         demote
     :param domain: a DN object of the server's domain
-- 
2.11.0



More information about the samba-technical mailing list