diff --git a/requirements.txt b/requirements.txt index 3e677ab..70f54d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ networkx>=1.8 jsonschema>=2.3.0 PyYAML>=3.11 +argparse>=1.2.1 diff --git a/setup.cfg b/setup.cfg index e092588..f168bbf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,6 +16,10 @@ classifier = Programming Language :: Python :: 2 Programming Language :: Python :: 2.6 +[entry_points] +console_scripts = + tasks-validator = tasks_validator.validator:main + [files] packages = tasks_validator diff --git a/tasks_validator/graph.py b/tasks_validator/graph.py new file mode 100644 index 0000000..c92d186 --- /dev/null +++ b/tasks_validator/graph.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import networkx as nx + + +class DeploymentGraph(object): + + def __init__(self, tasks): + self.tasks = tasks + self.graph = self._create_graph() + + def _create_graph(self): + """Create graph from tasks + + :return: directed graph + """ + graph = nx.DiGraph() + for task in self.tasks: + task_id = task['id'] + graph.add_node(task_id, **task) + if 'required_for' in task: + for req in task['required_for']: + graph.add_edge(task_id, req) + if 'requires' in task: + for req in task['requires']: + graph.add_edge(req, task_id) + + if 'groups' in task: + for req in task['groups']: + graph.add_edge(task_id, req) + if 'tasks' in task: + for req in task['tasks']: + graph.add_edge(req, task_id) + + return graph + + def find_cycles(self): + """Find cycles in graph. + + :return: list of cycles in graph + """ + cycles = [] + for cycle in nx.simple_cycles(self.graph): + cycles.append(cycle) + + return cycles + + def is_connected(self): + """Check if graph is connected. + + :return: bool + """ + return nx.is_weakly_connected(self.graph) + + def find_empty_nodes(self): + """Find empty nodes in graph. + + :return: list of empty nodes in graph + """ + empty_nodes = [] + for node_name, node in self.graph.node.items(): + if node == {}: + empty_nodes.append(node_name) + return empty_nodes diff --git a/tasks_validator/schemas.py b/tasks_validator/schemas.py new file mode 100644 index 0000000..ba41c3a --- /dev/null +++ b/tasks_validator/schemas.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +class BaseTasksSchema(object): + + base_task_schema = { + '$schema': 'http://json-schema.org/draft-04/schema#', + 'type': 'object', + 'required': ['type', 'id'], + 'properties': { + 'id': {'type': 'string', + 'minLength': 1}, + 'type': {'enum': [], + 'type': 'string'}, + 'parameters': {'type': 'object'}, + 'required_for': {'type': 'array'}, + 'requires': {'type': 'array'}, + }} + + base_tasks_schema = { + '$schema': 'http://json-schema.org/draft-04/schema#', + 'type': 'array', + 'items': ''} + + types = {'enum': ['puppet', 'shell'], + 'type': 'string'}, + + @property + def task_schema(self): + self.base_task_schema['properties']['type'] = self.types + return self.base_task_schema + + @property + def tasks_schema(self): + self.base_tasks_schema['items'] = self.task_schema + return self.base_tasks_schema + + +class TasksSchema61(BaseTasksSchema): + + types = {'enum': ['puppet', 'shell', 'group', 'stage', 'copy_files', + 'sync', 'upload_file'], + 'type': 'string'} + + +VERSIONS_SCHEMAS_MAP = { + "6.1": TasksSchema61, + "last": TasksSchema61, +} diff --git a/tasks_validator/tests/__init__.py b/tasks_validator/tests/__init__.py new file mode 100644 index 0000000..1f6e00f --- /dev/null +++ b/tasks_validator/tests/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. diff --git a/tasks_validator/tests/test_graph.py b/tasks_validator/tests/test_graph.py new file mode 100644 index 0000000..98ecce5 --- /dev/null +++ b/tasks_validator/tests/test_graph.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from unittest2.case import TestCase + +from tasks_validator import graph + + +class TestGraphs(TestCase): + + def test_connectability(self): + tasks = [ + {'id': 'pre_deployment_start', + 'type': 'stage'}, + {'id': 'pre_deployment_end', + 'type': 'stage', + 'requires': ['pre_deployment_start']}, + {'id': 'deploy_start', + 'type': 'stage'}] + tasks_graph = graph.DeploymentGraph(tasks) + self.assertFalse(tasks_graph.is_connected()) + + def test_cyclic(self): + tasks = [ + {'id': 'pre_deployment_start', + 'type': 'stage'}, + {'id': 'pre_deployment_end', + 'type': 'stage', + 'requires': ['pre_deployment_start']}, + {'id': 'deploy_start', + 'type': 'stage', + 'requires': ['pre_deployment_end'], + 'required_for': ['pre_deployment_start']}] + tasks_graph = graph.DeploymentGraph(tasks) + cycles = tasks_graph.find_cycles() + self.assertEqual(len(cycles), 1) + self.assertItemsEqual(cycles[0], ['deploy_start', + 'pre_deployment_start', + 'pre_deployment_end']) + + def test_empty_nodes(self): + tasks = [ + {'id': 'pre_deployment_start', + 'type': 'stage', + 'requires': ['empty_node']}, + {'id': 'pre_deployment_end', + 'type': 'stage', + 'requires': ['pre_deployment_start']}, + {'id': 'deploy_start', + 'type': 'stage', + 'requires': ['empty_node_2']}] + tasks_graph = graph.DeploymentGraph(tasks) + self.assertItemsEqual(tasks_graph.find_empty_nodes(), ['empty_node', + 'empty_node_2']) diff --git a/tasks_validator/tests/test_validator.py b/tasks_validator/tests/test_validator.py new file mode 100644 index 0000000..4c96d84 --- /dev/null +++ b/tasks_validator/tests/test_validator.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- + +# Copyright 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import copy +import jsonschema +import mock + +from unittest2.case import TestCase + +from tasks_validator import validator + + +TASKS = [ + {'id': 'pre_deployment_start', + 'type': 'stage'}, + {'id': 'pre_deployment_end', + 'requires': ['pre_deployment_start'], + 'type': 'stage'}, + {'id': 'deploy_start', + 'requires': ['pre_deployment_end'], + 'type': 'stage'}, + {'id': 'deploy_end', + 'requires': ['deploy_start'], + 'type': 'stage'}, + {'id': 'post_deployment_start', + 'requires': ['deploy_end'], + 'type': 'stage'}, + {'id': 'post_deployment_end', + 'requires': ['post_deployment_start'], + 'type': 'stage'}] + + +class TestValidator61(TestCase): + + def setUp(self): + self.tasks = copy.deepcopy(TASKS) + + def test_validate_schema(self): + valid_tasks = validator.TasksValidator(self.tasks, "6.1") + valid_tasks.validate_schema() + + def test_wrong_schema(self): + self.tasks.append({'id': 'wrong', + 'type': 'non existing'}) + valid_tasks = validator.TasksValidator(self.tasks, "6.1") + self.assertRaises(jsonschema.ValidationError, + valid_tasks.validate_schema) + + def test_empty_id_schema(self): + self.tasks.append({'id': '', + 'type': 'stage'}) + valid_tasks = validator.TasksValidator(self.tasks, "6.1") + self.assertRaises(jsonschema.ValidationError, + valid_tasks.validate_schema) + + def test_validate_graph(self): + valid_tasks = validator.TasksValidator(self.tasks, "6.1") + valid_tasks.validate_graph() + + def test_validate_cyclic_graph(self): + self.tasks.append({'id': 'post_deployment_part', + 'type': 'stage', + 'requires': ['post_deployment_start'], + 'required_for': ['pre_deployment_start']}) + valid_tasks = validator.TasksValidator(self.tasks, "6.1") + self.assertRaises(ValueError, + valid_tasks.validate_graph) + + def test_validate_not_connected_graph(self): + self.tasks.append({'id': 'post_deployment_part', + 'type': 'stage'}) + valid_tasks = validator.TasksValidator(self.tasks, "6.1") + self.assertRaises(ValueError, + valid_tasks.validate_graph) + + def test_validate_duplicated_tasks(self): + self.tasks.append({'id': 'pre_deployment_start', + 'type': 'stage'}) + valid_tasks = validator.TasksValidator(self.tasks, "6.1") + self.assertRaises(ValueError, + valid_tasks.validate_unique_tasks) + + def test_validate_empty_nodes(self): + self.tasks.append({'id': 'some_task', + 'type': 'stage', + 'requires': ['empty_node', + 'post_deployment_start']}) + valid_tasks = validator.TasksValidator(self.tasks, "6.1") + self.assertRaises(ValueError, + valid_tasks.validate_graph) + + +class TestValidatorClient(TestCase): + + def test_no_dir(self): + args = ['script/name'] + try: + validator.main(args) + except SystemExit as pars_error: + pass + self.assertEqual(pars_error[0], 2) + + @mock.patch('tasks_validator.validator.get_tasks') + @mock.patch('tasks_validator.validator.TasksValidator') + def test_passing_params(self, mock_validator, mock_file): + mock_file.return_value = TASKS + args = ['/usr/local/bin/tasks-validator', '-d', + './path', '-v', '6.1'] + validator.main(args) + mock_file.called_with('./path') + mock_validator.assert_called_with(TASKS, '6.1') diff --git a/tasks_validator/validator.py b/tasks_validator/validator.py new file mode 100644 index 0000000..4967ed3 --- /dev/null +++ b/tasks_validator/validator.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- + +# Copyright 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from __future__ import print_function + +import argparse +from fnmatch import fnmatch +import jsonschema +import logging +import os +import sys +import yaml + +from tasks_validator import graph +from tasks_validator.schemas import VERSIONS_SCHEMAS_MAP + +logging.basicConfig(level=logging.ERROR) +LOG = logging.getLogger(__name__) + + +class TasksValidator(object): + + def __init__(self, tasks, version): + self.version = version + self.tasks = tasks + self.graph = graph.DeploymentGraph(tasks) + + def validate_schema(self): + '''Validate tasks schema + + :raise: jsonschema.ValidationError + ''' + checker = jsonschema.FormatChecker() + schema = VERSIONS_SCHEMAS_MAP.get(self.version)().tasks_schema + jsonschema.validate(self.tasks, schema, format_checker=checker) + + def validate_unique_tasks(self): + '''Check if all tasks have unique ids + + :raise: ValueError when ids are duplicated + ''' + if not len(self.tasks) == len(set(task['id'] for task in self.tasks)): + raise ValueError("All tasks should have unique id") + + def validate_graph(self): + '''Validate graph if is executable completely by fuel nailgun + + :raise: ValueEror when one of requirements is not satisfied + ''' + msgs = [] + + # deployment graph should be without cycles + cycles = self.graph.find_cycles() + if len(cycles): + msgs.append('Graph is not acyclic. Cycles: {0}'.format(cycles)) + + # graph should be connected to execute all tasks + if not self.graph.is_connected(): + msgs.append('Graph is not connected.') + + # deployment graph should have filled all nodes + empty_nodes = self.graph.find_empty_nodes() + if len(empty_nodes): + msgs.append('Graph have empty nodes: {0}'.format(empty_nodes)) + + if msgs: + raise ValueError('Graph validation fail: {0}'.format(msgs)) + + +def get_files(base_dir, file_pattern='*tasks.yaml'): + for root, _dirs, files in os.walk(base_dir): + for file_name in files: + if fnmatch(file_name, file_pattern): + yield os.path.join(root, file_name) + + +def get_tasks(base_dir): + tasks = [] + for file_path in get_files(base_dir): + with open(file_path) as f: + LOG.debug('Reading tasks from file %s', file_path) + tasks.extend(yaml.load(f.read())) + return tasks + + +def main(args=sys.argv): + parser = argparse.ArgumentParser( + description='''Validator of tasks, gather all yaml files with name + contains tasks and read and validate tasks from them''') + parser.add_argument('-d', '--dir', dest='dir', required=True, + help='directory where tasks are localised') + parser.add_argument('-v', '--version', dest='ver', default='last', + metavar='VERSION', help='version of fuel for which ' + 'tasks should be validated') + parser.add_argument("--debug", dest="debug", action="store_true", + default=False, help="Enable debug mode") + args, _ = parser.parse_known_args(args) + + tasks = get_tasks(args.dir) + if args.debug: + LOG.setLevel(logging.DEBUG) + + if tasks: + t_validator = TasksValidator(tasks, args.ver) + t_validator.validate_schema() + t_validator.validate_unique_tasks() + t_validator.validate_graph() + else: + print("No tasks in the provided directory %s" % args.dir) + sys.exit(1) diff --git a/test-requirements.txt b/test-requirements.txt index e447160..a06846d 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1 +1,3 @@ pytest>=2.6.4 +unittest2>=0.5.1 +mock==1.0 \ No newline at end of file