From 7a9cd8abec0a238eed4d7234d5d58e83e144fcad Mon Sep 17 00:00:00 2001 From: Ilya Kutukov Date: Wed, 30 Mar 2016 14:18:18 +0300 Subject: [PATCH] Add custom graph upload and run support to the Fuel V2 CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following commands are added: * fuel2 graph upload --env env_id [--type graph_type] --file tasks.yaml * fuel2 graph upload --release release_id [--type graph_type] --file tasks.yaml * fuel2 graph upload --plugin plugin_id [--type graph_type] --file tasks.yaml --type is optional. ‘default’ graph type with confirmation should be used if no type is defined. * fuel2 graph execute --env env_id [--type graph_type] [--node node_ids] Graph execution available only for the environment. Change-Id: I33b168b7929b10200709efc58fd550f8779b39ae Partial-Bug: #1563851 DocImpact --- fuelclient/__init__.py | 1 + fuelclient/commands/graph.py | 150 ++++++++++++++++++ .../unit/v2/cli/test_deployment_graph.py | 88 ++++++++++ .../unit/v2/lib/test_deployment_graph.py | 117 ++++++++++++++ fuelclient/tests/utils/fake_task.py | 1 + fuelclient/v1/__init__.py | 2 + fuelclient/v1/graph.py | 97 +++++++++++ setup.cfg | 2 + 8 files changed, 458 insertions(+) create mode 100644 fuelclient/commands/graph.py create mode 100644 fuelclient/tests/unit/v2/cli/test_deployment_graph.py create mode 100644 fuelclient/tests/unit/v2/lib/test_deployment_graph.py create mode 100644 fuelclient/v1/graph.py diff --git a/fuelclient/__init__.py b/fuelclient/__init__.py index 63e6430..2ffc7a7 100644 --- a/fuelclient/__init__.py +++ b/fuelclient/__init__.py @@ -51,6 +51,7 @@ def get_client(resource, version='v1'): 'deployment_history': v1.deployment_history, 'environment': v1.environment, 'fuel-version': v1.fuelversion, + 'graph': v1.graph, 'network-group': v1.network_group, 'node': v1.node, 'openstack-config': v1.openstack_config, diff --git a/fuelclient/commands/graph.py b/fuelclient/commands/graph.py new file mode 100644 index 0000000..ef0a86f --- /dev/null +++ b/fuelclient/commands/graph.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2016 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 os + +from fuelclient.cli import error +from fuelclient.cli.serializers import Serializer +from fuelclient.commands import base + + +class FileMethodsMixin(object): + @classmethod + def check_file_path(cls, file_path): + if not os.path.exists(file_path): + raise error.InvalidFileException( + "File '{0}' doesn't exist.".format(file_path)) + + @classmethod + def check_dir(cls, directory): + if not os.path.exists(directory): + raise error.InvalidDirectoryException( + "Directory '{0}' doesn't exist.".format(directory)) + if not os.path.isdir(directory): + raise error.InvalidDirectoryException( + "Error: '{0}' is not a directory.".format(directory)) + + +class GraphUpload(base.BaseCommand, FileMethodsMixin): + """Upload deployment graph configuration.""" + entity_name = 'graph' + + @classmethod + def read_tasks_data_from_file(cls, file_path=None, serializer=None): + """Read Tasks data from given path. + + :param file_path: path + :type file_path: str + :param serializer: serializer object + :type serializer: object + :return: data + :rtype: list|object + """ + cls.check_file_path(file_path) + return (serializer or Serializer()).read_from_full_path(file_path) + + def get_parser(self, prog_name): + parser = super(GraphUpload, self).get_parser(prog_name) + graph_class = parser.add_mutually_exclusive_group() + + graph_class.add_argument('-e', + '--env', + type=int, + required=False, + help='Id of the environment') + graph_class.add_argument('-r', + '--release', + type=int, + required=False, + help='Id of the release') + graph_class.add_argument('-p', + '--plugin', + type=int, + required=False, + help='Id of the plugin') + + parser.add_argument('-t', + '--type', + type=str, + default=None, + required=False, + help='Type of the deployment graph') + parser.add_argument('-f', + '--file', + type=str, + required=True, + default=None, + help='YAML file that contains ' + 'deployment graph data.') + return parser + + def take_action(self, args): + parameters_to_graph_class = ( + ('env', 'clusters'), + ('release', 'releases'), + ('plugin', 'plugins'), + ) + + for parameter, graph_class in parameters_to_graph_class: + model_id = getattr(args, parameter) + if model_id: + self.client.upload( + data=self.read_tasks_data_from_file(args.file), + related_model=graph_class, + related_id=model_id, + graph_type=args.type + ) + break + + self.app.stdout.write( + "Deployment graph was uploaded from {0}\n".format(args.file) + ) + + +class GraphExecute(base.BaseCommand): + """Start deployment with given graph type.""" + entity_name = 'graph' + + def get_parser(self, prog_name): + parser = super(GraphExecute, self).get_parser(prog_name) + parser.add_argument('-e', + '--env', + type=int, + required=True, + help='Id of the environment') + parser.add_argument('-t', + '--type', + type=str, + default=None, + required=False, + help='Type of the deployment graph') + parser.add_argument('-n', + '--nodes', + type=int, + nargs='+', + required=False, + help='Ids of the nodes to use for deployment.') + return parser + + def take_action(self, args): + self.client.execute( + env_id=args.env, + graph_type=args.type, + nodes=args.nodes + ) + self.app.stdout.write( + "Deployment was executed\n" + ) diff --git a/fuelclient/tests/unit/v2/cli/test_deployment_graph.py b/fuelclient/tests/unit/v2/cli/test_deployment_graph.py new file mode 100644 index 0000000..512495a --- /dev/null +++ b/fuelclient/tests/unit/v2/cli/test_deployment_graph.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2016 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 mock +import yaml + +from fuelclient.tests.unit.v2.cli import test_engine + + +TASKS_YAML = '''- id: custom-task-1 + type: puppet + parameters: + param: value +- id: custom-task-2 + type: puppet + parameters: + param: value +''' + + +class TestGraphActions(test_engine.BaseCLITest): + + @mock.patch('fuelclient.commands.graph.os') + def _test_cmd(self, method, cmd_line, expected_kwargs, os_m): + os_m.exists.return_value = True + self.m_get_client.reset_mock() + self.m_client.get_filtered.reset_mock() + m_open = mock.mock_open(read_data=TASKS_YAML) + with mock.patch( + 'fuelclient.cli.serializers.open', m_open, create=True): + self.exec_command('graph {0} {1}'.format(method, cmd_line)) + self.m_get_client.assert_called_once_with('graph', mock.ANY) + self.m_client.__getattr__(method).assert_called_once_with( + **expected_kwargs) + + def test_upload(self): + self._test_cmd('upload', '--env 1 --file new_graph.yaml', dict( + data=yaml.load(TASKS_YAML), + related_model='clusters', + related_id=1, + graph_type=None + )) + self._test_cmd('upload', '--release 1 --file new_graph.yaml', dict( + data=yaml.load(TASKS_YAML), + related_model='releases', + related_id=1, + graph_type=None + )) + self._test_cmd('upload', '--plugin 1 --file new_graph.yaml', dict( + data=yaml.load(TASKS_YAML), + related_model='plugins', + related_id=1, + graph_type=None + )) + self._test_cmd( + 'upload', + '--plugin 1 --file new_graph.yaml --type custom_type', + dict( + data=yaml.load(TASKS_YAML), + related_model='plugins', + related_id=1, + graph_type='custom_type' + ) + ) + + def test_execute(self): + self._test_cmd( + 'execute', + '--env 1 --type custom_graph --nodes 1 2 3', + dict( + env_id=1, + graph_type='custom_graph', + nodes=[1, 2, 3] + ) + ) diff --git a/fuelclient/tests/unit/v2/lib/test_deployment_graph.py b/fuelclient/tests/unit/v2/lib/test_deployment_graph.py new file mode 100644 index 0000000..5eb71cf --- /dev/null +++ b/fuelclient/tests/unit/v2/lib/test_deployment_graph.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2016 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 mock +import yaml + +import fuelclient +from fuelclient.tests.unit.v2.lib import test_api +from fuelclient.tests.utils import fake_task + +TASKS_YAML = '''- id: custom-task-1 + type: puppet + parameters: + param: value +- id: custom-task-2 + type: puppet + parameters: + param: value +''' + + +class TestDeploymentGraphFacade(test_api.BaseLibTest): + + def setUp(self): + super(TestDeploymentGraphFacade, self).setUp() + self.version = 'v1' + self.client = fuelclient.get_client('graph', self.version) + self.env_id = 1 + + def test_existing_graph_upload(self): + expected_body = { + 'tasks': yaml.load(TASKS_YAML)} + + matcher_post = self.m_request.post( + '/api/v1/clusters/1/deployment_graphs/custom_graph', + json=expected_body) + + matcher_get = self.m_request.get( + '/api/v1/clusters/1/deployment_graphs/custom_graph', + status_code=404, + json={'status': 'error', 'message': 'Does not exist'}) + + m_open = mock.mock_open(read_data=TASKS_YAML) + with mock.patch( + 'fuelclient.cli.serializers.open', m_open, create=True): + self.client.upload( + data=expected_body, + related_model='clusters', + related_id=1, + graph_type='custom_graph' + ) + + self.assertTrue(matcher_get.called) + self.assertTrue(matcher_post.called) + self.assertItemsEqual( + expected_body, + matcher_post.last_request.json() + ) + + def test_new_graph_upload(self): + expected_body = { + 'tasks': yaml.load(TASKS_YAML)} + + matcher_put = self.m_request.put( + '/api/v1/clusters/1/deployment_graphs/custom_graph', + json=expected_body) + + matcher_get = self.m_request.get( + '/api/v1/clusters/1/deployment_graphs/custom_graph', + status_code=200, + json={ + 'tasks': [{'id': 'imatask', 'type': 'puppet'}] + }) + + m_open = mock.mock_open(read_data=TASKS_YAML) + with mock.patch( + 'fuelclient.cli.serializers.open', m_open, create=True): + self.client.upload( + data=expected_body, + related_model='clusters', + related_id=1, + graph_type='custom_graph') + + self.assertTrue(matcher_get.called) + self.assertTrue(matcher_put.called) + self.assertItemsEqual( + expected_body, + matcher_put.last_request.json() + ) + + def test_new_graph_run(self): + matcher_put = self.m_request.put( + '/api/v1/clusters/1/deploy/?nodes=1,2,3&graph_type=custom_graph', + json=fake_task.get_fake_task(cluster=370)) + # this is required to form running task info + self.m_request.get( + '/api/v1/nodes/?cluster_id=370', + json={} + ) + self.client.execute( + env_id=1, + nodes=[1, 2, 3], + graph_type="custom_graph") + self.assertTrue(matcher_put.called) diff --git a/fuelclient/tests/utils/fake_task.py b/fuelclient/tests/utils/fake_task.py index 6b9c520..59c7f71 100644 --- a/fuelclient/tests/utils/fake_task.py +++ b/fuelclient/tests/utils/fake_task.py @@ -25,6 +25,7 @@ def get_fake_task(task_id=None, status=None, name=None, """ return {'status': status or 'running', 'name': name or 'deploy', + 'id': task_id or 42, 'task_id': task_id or 42, 'cluster': cluster or 34, 'result': result or '', diff --git a/fuelclient/v1/__init__.py b/fuelclient/v1/__init__.py index 8559e5e..6918556 100644 --- a/fuelclient/v1/__init__.py +++ b/fuelclient/v1/__init__.py @@ -15,6 +15,7 @@ from fuelclient.v1 import deployment_history from fuelclient.v1 import environment from fuelclient.v1 import fuelversion +from fuelclient.v1 import graph from fuelclient.v1 import network_group from fuelclient.v1 import node from fuelclient.v1 import openstack_config @@ -26,6 +27,7 @@ from fuelclient.v1 import vip __all__ = ('deployment_history', 'environment', 'fuelversion', + 'graph', 'network_group', 'node', 'openstack_config', diff --git a/fuelclient/v1/graph.py b/fuelclient/v1/graph.py new file mode 100644 index 0000000..3d185a1 --- /dev/null +++ b/fuelclient/v1/graph.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2016 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 fuelclient.cli import error +from fuelclient.client import APIClient +from fuelclient import objects +from fuelclient.v1 import base_v1 + + +class GraphClient(base_v1.BaseV1Client): + _entity_wrapper = objects.Environment + + related_graphs_list_api_path = "{related_model}/{related_model_id}" \ + "/deployment_graphs/" + + related_graph_api_path = "{related_model}/{related_model_id}" \ + "/deployment_graphs/{graph_type}" + + cluster_deploy_api_path = "clusters/{env_id}/deploy/" + + @classmethod + def update_graph_for_model( + cls, data, related_model, related_model_id, graph_type=None): + return APIClient.put_request( + cls.related_graph_api_path.format( + related_model=related_model, + related_model_id=related_model_id, + graph_type=graph_type or ""), + data + ) + + @classmethod + def create_graph_for_model( + cls, data, related_model, related_model_id, graph_type=None): + return APIClient.post_request( + cls.related_graph_api_path.format( + related_model=related_model, + related_model_id=related_model_id, + graph_type=graph_type or ""), + data + ) + + @classmethod + def get_graph_for_model( + cls, related_model, related_model_id, graph_type=None): + return APIClient.get_request( + cls.related_graph_api_path.format( + related_model=related_model, + related_model_id=related_model_id, + graph_type=graph_type or "")) + + def upload(self, data, related_model, related_id, graph_type): + # create or update + try: + self.get_graph_for_model( + related_model, related_id, graph_type) + self.update_graph_for_model( + {'tasks': data}, related_model, related_id, graph_type) + except error.HTTPError as exc: + if '404' in exc.message: + self.create_graph_for_model( + {'tasks': data}, related_model, related_id, graph_type) + + @classmethod + def execute(cls, env_id, nodes, graph_type=None): + put_args = [] + + if nodes: + put_args.append("nodes={0}".format(",".join(map(str, nodes)))) + + if graph_type: + put_args.append(("graph_type=" + graph_type)) + + url = "".join([ + cls.cluster_deploy_api_path.format(env_id=env_id), + '?', + '&'.join(put_args)]) + + deploy_data = APIClient.put_request(url, {}) + return objects.DeployTask.init_with_data(deploy_data) + + +def get_client(): + return GraphClient() diff --git a/setup.cfg b/setup.cfg index 7abd4a0..177c4c6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,6 +39,8 @@ fuelclient = env_spawn-vms=fuelclient.commands.environment:EnvSpawnVms env_update=fuelclient.commands.environment:EnvUpdate fuel-version=fuelclient.commands.fuelversion:FuelVersion + graph_execute=fuelclient.commands.graph:GraphExecute + graph_upload=fuelclient.commands.graph:GraphUpload network-group_create=fuelclient.commands.network_group:NetworkGroupCreate network-group_delete=fuelclient.commands.network_group:NetworkGroupDelete network-group_list=fuelclient.commands.network_group:NetworkGroupList