summaryrefslogtreecommitdiff
path: root/python/samba/kcc/graph_utils.py
blob: c89d06ad3b7d0327ad3ec109455b30c451b42d16 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# Graph topology utilities, used by KCC
#
# Copyright (C) Andrew Bartlett 2015
#
# Copyright goes to Andrew Bartlett, but the actual work was performed
# by Douglas Bagnall and Garming Sam.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import os
import itertools

from samba.graph import dot_graph


def write_dot_file(basename, edge_list, vertices=None, label=None,
                   dot_file_dir=None, debug=None, **kwargs):
    s = dot_graph(vertices, edge_list, title=label, **kwargs)
    if label:
        # sanitise DN and guid labels
        basename += '_' + label.replace(', ', '')

    filename = os.path.join(dot_file_dir, "%s.dot" % basename)
    if debug is not None:
        debug("writing graph to %s" % filename)
    f = open(filename, 'w')
    f.write(s)
    f.close()


class GraphError(Exception):
    pass


def verify_graph_complete(edges, vertices, edge_vertices):
    """The graph is complete, which is to say there is an edge between
    every pair of nodes."""
    for v in vertices:
        remotes = set()
        for a, b in edges:
            if a == v:
                remotes.add(b)
            elif b == v:
                remotes.add(a)
        if len(remotes) + 1 != len(vertices):
            raise GraphError("graph is not fully connected")


def verify_graph_connected(edges, vertices, edge_vertices):
    """There is a path between any two nodes."""
    if not edges:
        if len(vertices) <= 1:
            return
        raise GraphError("all vertices are disconnected because "
                         "there are no edges:")

    remaining_edges = list(edges)
    reached = set(remaining_edges.pop())
    while True:
        doomed = []
        for i, e in enumerate(remaining_edges):
            a, b = e
            if a in reached:
                reached.add(b)
                doomed.append(i)
            elif b in reached:
                reached.add(a)
                doomed.append(i)
        if not doomed:
            break
        for i in reversed(doomed):
            del remaining_edges[i]

    if remaining_edges or reached != set(vertices):
        s = ("the graph is not connected, "
             "as the following vertices are unreachable:\n ")
        s += '\n '.join(v for v in sorted(vertices)
                        if v not in reached)
        raise GraphError(s)


def verify_graph_connected_under_edge_failures(edges, vertices, edge_vertices):
    """The graph stays connected when any single edge is removed."""
    if len(edges) == 0:
        return verify_graph_connected(edges, vertices, edge_vertices)

    for subset in itertools.combinations(edges, len(edges) - 1):
        try:
            verify_graph_connected(subset, vertices, edge_vertices)
        except GraphError as e:
            for edge in edges:
                if edge not in subset:
                    raise GraphError("The graph will be disconnected when the "
                                     "connection from %s to %s fails" % edge)


def verify_graph_connected_under_vertex_failures(edges, vertices,
                                                 edge_vertices):
    """The graph stays connected when any single vertex is removed."""
    for v in vertices:
        sub_vertices = [x for x in vertices if x is not v]
        sub_edges = [x for x in edges if v not in x]
        verify_graph_connected(sub_edges, sub_vertices, sub_vertices)


def verify_graph_forest(edges, vertices, edge_vertices):
    """The graph contains no loops."""
    trees = [set(e) for e in edges]
    while True:
        for a, b in itertools.combinations(trees, 2):
            intersection = a & b
            if intersection:
                if len(intersection) == 1:
                    a |= b
                    trees.remove(b)
                    break
                else:
                    raise GraphError("there is a loop in the graph\n"
                                     " vertices %s\n edges %s\n"
                                     " intersection %s" %
                                     (vertices, edges, intersection))
        else:
            # no break in itertools.combinations loop means no
            # further mergers, so we're done.
            #
            # XXX here we also know whether it is a tree or a
            # forest by len(trees) but the connected test already
            # tells us that.
            return


def verify_graph_multi_edge_forest(edges, vertices, edge_vertices):
    """This allows a forest with duplicate edges. That is if multiple
    edges go between the same two vertices, they are treated as a
    single edge by this test.

    e.g.:
                        o
    pass: o-o=o  o=o   (|)             fail:  o-o
            `o          o                     `o'
    """
    unique_edges = set(edges)
    trees = [set(e) for e in unique_edges]
    while True:
        for a, b in itertools.combinations(trees, 2):
            intersection = a & b
            if intersection:
                if len(intersection) == 1:
                    a |= b
                    trees.remove(b)
                    break
                else:
                    raise GraphError("there is a loop in the graph")
        else:
            return


def verify_graph_no_lonely_vertices(edges, vertices, edge_vertices):
    """There are no vertices without edges."""
    lonely = set(vertices) - set(edge_vertices)
    if lonely:
        raise GraphError("some vertices are not connected:\n%s" %
                         '\n'.join(sorted(lonely)))


def verify_graph_no_unknown_vertices(edges, vertices, edge_vertices):
    """The edge endpoints contain no vertices that are otherwise unknown."""
    unknown = set(edge_vertices) - set(vertices)
    if unknown:
        raise GraphError("some edge vertices are seemingly unknown:\n%s" %
                         '\n'.join(sorted(unknown)))


def verify_graph_directed_double_ring(edges, vertices, edge_vertices):
    """Each node has at least two directed edges leaving it, and two
    arriving. The edges work in pairs that have the same end points
    but point in opposite directions. The pairs form a path that
    touches every vertex and form a loop.

    There might be other connections that *aren't* part of the ring.

    Deciding this for sure is NP-complete (the Hamiltonian path
    problem), but there are some easy failures that can be detected.
    So far we check for:
      - leaf nodes
      - disjoint subgraphs
      - robustness against edge and vertex failure
    """
    # a zero or one node graph is OK with no edges.
    # The two vertex case is special. Use
    # verify_graph_directed_double_ring_or_small() to allow that.
    if not edges and len(vertices) <= 1:
        return
    if len(edges) < 2 * len(vertices):
        raise GraphError("directed double ring requires at least twice "
                         "as many edges as vertices")

    # Reduce the problem space by looking only at bi-directional links.
    half_duplex = set(edges)
    duplex_links = set()
    for edge in edges:
        rev_edge = (edge[1], edge[0])
        if edge in half_duplex and rev_edge in half_duplex:
            duplex_links.add(edge)
            half_duplex.remove(edge)
            half_duplex.remove(rev_edge)

    # the Hamiltonian cycle problem is NP-complete in general, but we
    # can cheat a bit and prove a less strong result.
    #
    # We declutter the graph by replacing nodes with edges connecting
    # their neighbours.
    #
    #       A-B-C --> A-C
    #
    #    -A-B-C-   -->  -A--C-
    #       `D_           `D'_
    #
    # In the end there should be a single 2 vertex graph.

    edge_map = {}
    for a, b in duplex_links:
        edge_map.setdefault(a, set()).add(b)
        edge_map.setdefault(b, set()).add(a)

    # an easy to detect failure is a lonely leaf node
    for vertex, neighbours in edge_map.items():
        if len(neighbours) == 1:
            raise GraphError("wanted double directed ring, found a leaf node"
                             "(%s)" % vertex)

    for vertex in list(edge_map.keys()):
        nset = edge_map[vertex]
        if not nset:
            continue
        for n in nset:
            n_neighbours = edge_map[n]
            n_neighbours.remove(vertex)
            n_neighbours.update(x for x in nset if x != n)
        del edge_map[vertex]

    if len(edge_map) > 1:
        raise GraphError("wanted double directed ring, but "
                         "this looks like a split graph\n"
                         "(%s can't reach each other)" %
                         ', '.join(edge_map.keys()))

    verify_graph_connected_under_edge_failures(duplex_links, vertices,
                                               edge_vertices)
    verify_graph_connected_under_vertex_failures(duplex_links, vertices,
                                                 edge_vertices)


def verify_graph_directed_double_ring_or_small(edges, vertices, edge_vertices):
    """This performs the directed_double_ring test but makes special
    concessions for small rings where the strict rules don't really
    apply."""
    if len(vertices) < 2:
        return
    if len(vertices) == 2:
        """With 2 nodes there should be a single link in each directions."""
        if (len(edges) == 2 and
            edges[0][0] == edges[1][1] and
            edges[0][1] == edges[1][0]):
            return
        raise GraphError("A two vertex graph should have an edge each way.")

    return verify_graph_directed_double_ring(edges, vertices, edge_vertices)


def verify_graph(edges, vertices=None, directed=False, properties=()):
    errors = []
    properties = [x.replace(' ', '_') for x in properties]

    edge_vertices = set()
    for a, b in edges:
        edge_vertices.add(a)
        edge_vertices.add(b)

    if vertices is None:
        vertices = edge_vertices
    else:
        vertices = set(vertices)

    for p in properties:
        fn = 'verify_graph_%s' % p
        f = globals()[fn]
        try:
            f(edges, vertices, edge_vertices)
        except GraphError as e:
            errors.append((p, e, f.__doc__))

    return errors


def verify_and_dot(basename, edges, vertices=None, label=None,
                   reformat_labels=True, directed=False,
                   properties=(), fatal=True, debug=None,
                   verify=True, dot_file_dir=None,
                   edge_colors=None, edge_labels=None,
                   vertex_colors=None):

    if dot_file_dir is not None:
        write_dot_file(basename, edges, vertices=vertices, label=label,
                       dot_file_dir=dot_file_dir,
                       reformat_labels=reformat_labels, directed=directed,
                       debug=debug, edge_colors=edge_colors,
                       edge_labels=edge_labels, vertex_colors=vertex_colors)

    if verify:
        errors = verify_graph(edges, vertices,
                              properties=properties)
        if errors:
            title = '%s %s' % (basename, label or '')
            debug("%s FAILED:" % title)
            for p, e, doc in errors:
                debug(" %18s: %s" % (p, e))
            if fatal:
                raise GraphError("The '%s' graph lacks the following "
                                 "properties:\n%s" %
                                 (title, '\n'.join('%s: %s' % (p, e)
                                                   for p, e, doc in errors)))


def list_verify_tests():
    for k, v in sorted(globals().items()):
        if k.startswith('verify_graph_'):
            print(k.replace('verify_graph_', ''))
            if v.__doc__:
                print('    %s' % (v.__doc__.rstrip()))
            else:
                print()