diff --git a/cdist/config.py b/cdist/config.py index fae2ff46..4e226b3a 100644 --- a/cdist/config.py +++ b/cdist/config.py @@ -43,6 +43,33 @@ from cdist import core, inventory from cdist.util.remoteutil import inspect_ssh_mux_opts +def graph_check_cycle(graph): + # Start from each node in the graph and check for cycle starting from it. + for node in graph: + # Cycle path. + path = [node] + has_cycle = _graph_dfs_cycle( graph, node, path) + if has_cycle: + return has_cycle, path + return False, None + + +def _graph_dfs_cycle(graph, node, path): + for neighbour in graph.get(node, ()): + # If node is already in path then this is cycle. + if neighbour in path: + path.append(neighbour) + return True + path.append(neighbour) + rv = _graph_dfs_cycle(graph, neighbour, path) + if rv: + return True + # Remove last item from list - neighbour whose DFS path we have have + # just checked. + del path[-1] + return False + + class Config(object): """Cdist main class to hold arbitrary data""" @@ -254,14 +281,14 @@ class Config(object): cls.onehost(host, host_tags, host_base_path, hostdir, args, parallel=False, configuration=configuration) - except cdist.Error as e: + except cdist.Error: failed_hosts.append(host) if args.parallel and len(process_args) == 1: log.debug("Only 1 host for parallel processing, doing it " "sequentially") try: cls.onehost(*process_args[0]) - except cdist.Error as e: + except cdist.Error: failed_hosts.append(host) elif args.parallel: log.trace("Multiprocessing start method is {}".format( @@ -653,6 +680,28 @@ class Config(object): self.__dict__.update(state) self._open_logger() + def _validate_dependencies(self): + ''' + Build dependency graph for unfinished objects and + check for cycles. + ''' + graph = {} + for cdist_object in self.object_list(): + obj_name = cdist_object.name + if obj_name not in graph: + graph[obj_name] = [] + if cdist_object.state == cdist_object.STATE_DONE: + continue + + for requirement in cdist_object.requirements_unfinished( + cdist_object.requirements): + graph[obj_name].append(requirement.name) + + for requirement in cdist_object.requirements_unfinished( + cdist_object.autorequire): + graph[obj_name].append(requirement.name) + return graph_check_cycle(graph) + def iterate_until_finished(self): """ Go through all objects and solve them @@ -662,6 +711,12 @@ class Config(object): objects_changed = True while objects_changed: + # Check for cycles as early as possible. + has_cycle, path = self._validate_dependencies() + if has_cycle: + raise cdist.UnresolvableRequirementsError( + "Cycle detected in object dependencies:\n{}!".format( + " -> ".join(path))) objects_changed = self.iterate_once() # Check whether all objects have been finished diff --git a/cdist/test/config/__init__.py b/cdist/test/config/__init__.py index 2b0d8b5f..499593e3 100644 --- a/cdist/test/config/__init__.py +++ b/cdist/test/config/__init__.py @@ -23,7 +23,6 @@ import os import shutil -import tempfile from cdist import test from cdist import core @@ -212,7 +211,7 @@ class ConfigRunTestCase(test.CdistTestCase): dryrun.run() # if we are here, dryrun works like expected - def test_desp_resolver(self): + def test_deps_resolver(self): """Test to show dependency resolver warning message.""" local = cdist.exec.local.Local( target_host=self.target_host, @@ -229,6 +228,71 @@ class ConfigRunTestCase(test.CdistTestCase): config = cdist.config.Config(local, self.remote, dry_run=True) config.run() + def test_graph_check_cycle_empty(self): + graph = {} + has_cycle, path = cdist.config.graph_check_cycle(graph) + self.assertFalse(has_cycle) + + def test_graph_check_cycle_1(self): + # + # a -> b -> c + # | + # +--> d -> e + graph = { + 'a': ['b', ], + 'b': ['c', 'd', ], + 'd': ['e', ], + } + has_cycle, path = cdist.config.graph_check_cycle(graph) + self.assertFalse(has_cycle) + + def test_graph_check_cycle_2(self): + # + # a -> b -> c + # /\ | + # \ | + # +-------+ + graph = { + 'a': ['b', ], + 'b': ['c', ], + 'c': ['a', ], + } + has_cycle, path = cdist.config.graph_check_cycle(graph) + self.assertTrue(has_cycle) + self.assertGreater(path.count(path[-1]), 1) + + def test_graph_check_cycle_3(self): + # + # a -> b -> c + # \ \ + # \ +--> g + # \ /\ + # \ /| + # +-> d -> e | + # \ | + # + --> f + # + # h -> i --> j + # | /\ | + # \/ | \/ + # n m <- k + graph = { + 'a': ['b', 'd', ], + 'b': ['c', ], + 'c': ['g', ], + 'd': ['e', 'f', ], + 'e': ['g', ], + 'f': ['g', ], + 'h': ['i', 'n', ], + 'i': ['j', ], + 'j': ['k', ], + 'k': ['m', ], + 'm': ['i', ], + } + has_cycle, path = cdist.config.graph_check_cycle(graph) + self.assertTrue(has_cycle) + self.assertGreater(path.count(path[-1]), 1) + # Currently the resolving code will simply detect that this object does # not exist. It should probably check if the type is a singleton as well