[PATCH] samba-tool visualize uptodateness

Douglas Bagnall douglas.bagnall at catalyst.net.nz
Sat Jun 9 04:56:58 UTC 2018


These patches introduce a new samba-tool visualize subcommand,
"uptodateness", which tries to visualise lag in the DRS replication
up-to-date-ness vectors.

Each DC maintains a monotonically increasing update sequence number
(USN) which increments whenever the DC gains some new replicable
information, whether it invents the information itself, receives it
from a client, or replicates it from some other DC. The absolute value
of this number is a bit meaningless (unless you care how experienced
your DCs are), and it is not useful to compare the USNs of different
DCs. Usually the only thing we care about is whether a USN has
changed, though for this we are using the amount of change.

Each DC also has a structure mapping other DCs to their highest USNs
at the time it last replicated from them. This is the "uptodateness
vector". If an AD network has working replication and is quiescent for
a while, every DC's uptodateness vector will be up to date -- meaning
that the last known USN they have for each other DC will equal that
DC's current USN. Any activity will cause a ripple of USN updates,
thence uptodateness vector updates, and while all this is sloshing
around the vectors will lag behind the USNs. At any given moment in a
busy network you are likely to see small uptodateness gaps. On the
other hand, if replication is broken the uptodateness vectors will not
update and the gaps will seen be huge.

What makes the uptodateness discrepancy a useful symptom is its
independence from the AD network's view of itself -- the KCC can be as
happy or as upset as it likes with all its reps-to/from and
ntds-connections, but the uptodateness will show if replication is
actually working. That is the value of the new visualisation. On the
other hand, it tells you absolutely nothing about why things are
failing.

So what `samba-tool visualize uptodateness` does is print a matrix of
the differences between all the USNs and the uptodateness vectors. For
example, this:

samba-tool visualize uptodateness -r \
    -H ldap://$SERVER \
    -U $CREDS \
    --utf8 -S \
    --color=no \
    --partition DOMAIN

produces something like this:

-------------------------8<--------------------------

DOMAIN

                                    out-of-date-ness
                     ╭───────────── CN=LOCALDC+
                     │   ╭───────── CN=LOCALVAMPIREDC+
                DC   │   │   ╭───── CN=PROMOTEDVDC+
       CN=LOCALDC+   · 127   6 
CN=LOCALVAMPIREDC+   1   ·   6 
   CN=PROMOTEDVDC+   1 127   · 

'+' stands for ',CN=Servers,CN=Default-First-Site-Name,CN=Sites,CN=Configuration,DC=samba,DC=example,DC=com'

-------------------------8<--------------------------

where the "CN=LOCALDC+" row says that localdc's uptodateness value for
localvampiredc's highest USN is 127 behind, while that for promotedvdc
is 6 behind. If it is more than 999 behind, the number is represented
as ">", like this:

                                    out-of-date-ness
                     ╭───────────── CN=LOCALDC+
                     │   ╭───────── CN=LOCALVAMPIREDC+
                DC   │   │   ╭───── CN=PROMOTEDVDC+
       CN=LOCALDC+   ·   >   6 
CN=LOCALVAMPIREDC+   1   ·   6 
   CN=PROMOTEDVDC+   1 127   · 


If you see a ">", you probably have trouble (this threshold can be
adjust to (e.g.) 99 or 9999 but using --digits=2 or --digits=4). With
--color=yes you get a heat map behind the numbers; green is good, red
is bad, and there's a series of middling yellows in between.

The patched files are all python with the most involved bits trying to
the ascii art routines deal with a new kind of data structure.


Douglas

P.S. I *will* add some documentation, but I had to write this email to
realise the need for that.
-------------- next part --------------
From 24f20f6513af19b1f8b0f35ce645254e45b97c96 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 76a94dfaa188e186c17d9e1a357fcb8c30550bd9 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 74e557ec9d75d8f1e0017e94c5793b521475e2cd 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 61f0ab7c13ba07eecf872ed97975202be80ea245 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 78075f07ca3c36a5df9947cd55afc15af26301a7 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 a70c2b967da6e6881ac92f27d6949a938152e75e 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..a4dfacda726 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 = 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 = 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 f6e05eb31ea0fcdbca7fba60778139debc4d115a 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 a4dfacda726..2024f969d18 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 d206e41d3a2d8109a0bb4046b4ea9d1ed58823ca 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 2024f969d18..5fef37b45b5 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 b284dd78c9c4b9847272a2b1696608b37c3e5161 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 99edbda115bb728b5f4cd5db3fe4afea51666137 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 53700ff09652eb177bf38faf666dc36400b028f8 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               | 135 ++++++++-
 python/samba/tests/samba_tool/visualize_drs.py | 399 ++++++++++++++++++++++++-
 2 files changed, 530 insertions(+), 4 deletions(-)

diff --git a/python/samba/netcmd/visualize.py b/python/samba/netcmd/visualize.py
index bfd7d3bf341..fa142ac2d3f 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,137 @@ 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 = []
+        for c in dsdb._dsdb_load_udv_v2(samdb, dn):
+            inv_id = str(c.source_dsa_invocation_id)
+            res = samdb.search(expression=("(&(invocationId=%s)"
+                                           "(objectClass=nTDSDSA))" % inv_id),
+                               #scope=SCOPE_SUBTREE,
+                               controls=["search_options:1:2"],
+                               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:
+                peak = utdv_edges[dn1][dn1]
+                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..dfac4833029 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,338 @@ 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 much
+
+        (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])
+        self.assertEqual(matrix[dc2][dc1], 0)
+
+        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])
+        self.assertEqual(matrix[dc1][dc2], 0)
+
+        (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 132b398b6dcdb296bda296a438acbf1dd05c180d 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 ddb37735af31245e98fbc1a7d66ddd876e6d422d 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