From 820c7c488f95c6843aee414fa964891805fa358b Mon Sep 17 00:00:00 2001 From: Vitalii Myhal Date: Sat, 12 Mar 2016 14:26:45 -0600 Subject: [PATCH 01/11] Introduced command 'env redeploy' in v2 version of fuelclient Example: `fuel2 env redeploy ` Fixed deployment tests. Fuel-web repo has been changed from 'stackforge' to 'openstack'. Partial-Bug: 1540558 Change-Id: I6bc18ff672bc119c523841033fde4bbacb9290fd Depends-On: Ibc89fdbfbd0a36a890412cd8e861d35bcf930690 --- fuelclient/commands/environment.py | 25 +++++- fuelclient/consts.py | 17 ++++ fuelclient/tests/functional/base.py | 77 +++++++++++++++++-- fuelclient/tests/functional/v1/test_client.py | 52 +++++++------ fuelclient/tests/functional/v2/test_client.py | 56 ++++++++++++++ fuelclient/tests/unit/v2/cli/test_env.py | 7 ++ fuelclient/v1/environment.py | 6 ++ setup.cfg | 1 + tox.ini | 2 +- 9 files changed, 213 insertions(+), 30 deletions(-) create mode 100644 fuelclient/tests/functional/v2/test_client.py diff --git a/fuelclient/commands/environment.py b/fuelclient/commands/environment.py index c5fad0c..98d14c8 100644 --- a/fuelclient/commands/environment.py +++ b/fuelclient/commands/environment.py @@ -201,14 +201,35 @@ class EnvDeploy(EnvMixIn, base.BaseCommand): parser.add_argument('id', type=int, - help='Id of the nailgun entity to be processed.') + help='Id of the environment to be deployed.') return parser def take_action(self, parsed_args): task_id = self.client.deploy_changes(parsed_args.id) - msg = 'Deploy task with id {t} for the environment {e} '\ + msg = 'Deployment task with id {t} for the environment {e} '\ + 'has been started.\n'.format(t=task_id, e=parsed_args.id) + + self.app.stdout.write(msg) + + +class EnvRedeploy(EnvMixIn, base.BaseCommand): + """Redeploys changes on the specified environment.""" + + def get_parser(self, prog_name): + parser = super(EnvRedeploy, self).get_parser(prog_name) + + parser.add_argument('id', + type=int, + help='Id of the environment to be redeployed.') + + return parser + + def take_action(self, parsed_args): + task_id = self.client.redeploy_changes(parsed_args.id) + + msg = 'Deployment task with id {t} for the environment {e} '\ 'has been started.\n'.format(t=task_id, e=parsed_args.id) self.app.stdout.write(msg) diff --git a/fuelclient/consts.py b/fuelclient/consts.py index 583dc62..f76aebc 100644 --- a/fuelclient/consts.py +++ b/fuelclient/consts.py @@ -14,4 +14,21 @@ # License for the specific language governing permissions and limitations # under the License. +from collections import namedtuple + + +def Enum(*values, **kwargs): + names = kwargs.get('names') + if names: + return namedtuple('Enum', names)(*values) + return namedtuple('Enum', values)(*values) + + SERIALIZATION_FORMAT_FLAG = 'serialization_format' + +TASK_STATUSES = Enum( + 'error', + 'pending', + 'ready', + 'running' +) diff --git a/fuelclient/tests/functional/base.py b/fuelclient/tests/functional/base.py index 349652c..1933830 100644 --- a/fuelclient/tests/functional/base.py +++ b/fuelclient/tests/functional/base.py @@ -12,24 +12,27 @@ # License for the specific language governing permissions and limitations # under the License. +import json import logging import os +import re import shutil import subprocess import sys import tempfile +import time -from oslotest import base as oslo_base - +from fuelclient import consts from fuelclient.objects import Release +from oslotest import base as oslo_base logging.basicConfig(stream=sys.stderr) log = logging.getLogger("CliTest.ExecutionLog") log.setLevel(logging.DEBUG) -class CliExectutionResult(object): +class CliExecutionResult(object): def __init__(self, process_handle, out, err): self.return_code = process_handle.returncode self.stdout = out @@ -45,6 +48,8 @@ class CliExectutionResult(object): class BaseTestCase(oslo_base.BaseTestCase): + + handler = '' nailgun_root = os.environ.get('NAILGUN_ROOT', '/tmp/fuel_web/nailgun') def setUp(self): @@ -91,7 +96,7 @@ class BaseTestCase(oslo_base.BaseTestCase): def run_cli_command(self, command_line, check_errors=True, env=os.environ.copy()): - command_args = [" ".join(('fuel', command_line))] + command_args = [" ".join((self.handler, command_line))] process_handle = subprocess.Popen( command_args, stdout=subprocess.PIPE, @@ -100,7 +105,7 @@ class BaseTestCase(oslo_base.BaseTestCase): env=env ) out, err = process_handle.communicate() - result = CliExectutionResult(process_handle, out, err) + result = CliExecutionResult(process_handle, out, err) log.debug("command_args: '%s',stdout: '%s', stderr: '%s'", command_args[0], out, err) if check_errors: @@ -129,6 +134,12 @@ class BaseTestCase(oslo_base.BaseTestCase): call = self.run_cli_command(command, check_errors=check_errors) self.assertEqual(call.stdout, msg) + def check_for_stdout_by_regexp(self, command, pattern, check_errors=True): + call = self.run_cli_command(command, check_errors=check_errors) + result = re.search(pattern, call.stdout) + self.assertIsNotNone(result) + return result + def check_for_stderr(self, command, msg, check_errors=True): call = self.run_cli_command(command, check_errors=check_errors) self.assertIn(msg, call.stderr) @@ -147,3 +158,59 @@ class BaseTestCase(oslo_base.BaseTestCase): def check_number_of_rows_in_table(self, command, number_of_rows): output = self.run_cli_command(command) self.assertEqual(len(output.stdout.split("\n")), number_of_rows + 3) + + def _get_task_info(self, task_id): + """Get info about task with given ID. + + :param task_id: Task ID + :type task_id: str or int + :return: Task info + :rtype: dict + """ + return {} + + def wait_task_ready(self, task_id, timeout=60, interval=3): + """Wait for changing task status to 'ready'. + + :param task_id: Task ID + :type task_id: str or int + :param timeout: Max time of waiting, in seconds + :type timeout: int + :param interval: Interval of getting task info, in seconds + :type interval: int + """ + wait_until_in_statuses = (consts.TASK_STATUSES.running, + consts.TASK_STATUSES.pending) + timer = time.time() + while True: + task = self._get_task_info(task_id) + status = task.get('status', '') + if status not in wait_until_in_statuses: + self.assertEqual(status, consts.TASK_STATUSES.ready) + break + + if time.time() - timer > timeout: + raise Exception( + "Task '{0}' seems to be hanged".format(task['name']) + ) + time.sleep(interval) + + +class CLIv1TestCase(BaseTestCase): + + handler = 'fuel' + + def _get_task_info(self, task_id): + command = "task --task {0} --json".format(str(task_id)) + call = self.run_cli_command(command) + return json.loads(call.stdout)[0] + + +class CLIv2TestCase(BaseTestCase): + + handler = 'fuel2' + + def _get_task_info(self, task_id): + command = "task show -f json {0}".format(str(task_id)) + call = self.run_cli_command(command) + return json.loads(call.stdout) diff --git a/fuelclient/tests/functional/v1/test_client.py b/fuelclient/tests/functional/v1/test_client.py index a8ada80..06c31c9 100644 --- a/fuelclient/tests/functional/v1/test_client.py +++ b/fuelclient/tests/functional/v1/test_client.py @@ -20,7 +20,7 @@ import tempfile from fuelclient.tests.functional import base -class TestHandlers(base.BaseTestCase): +class TestHandlers(base.CLIv1TestCase): def test_env_action(self): # check env help @@ -289,7 +289,7 @@ class TestHandlers(base.BaseTestCase): ) -class TestCharset(base.BaseTestCase): +class TestCharset(base.CLIv1TestCase): def test_charset_problem(self): self.load_data_to_nailgun_server() @@ -301,7 +301,7 @@ class TestCharset(base.BaseTestCase): )) -class TestFiles(base.BaseTestCase): +class TestFiles(base.CLIv1TestCase): def test_file_creation(self): self.load_data_to_nailgun_server() @@ -393,7 +393,7 @@ class TestFiles(base.BaseTestCase): )) -class TestDownloadUploadNodeAttributes(base.BaseTestCase): +class TestDownloadUploadNodeAttributes(base.CLIv1TestCase): def test_upload_download_interfaces(self): self.load_data_to_nailgun_server() @@ -415,35 +415,43 @@ class TestDownloadUploadNodeAttributes(base.BaseTestCase): self.upload_command(cmd))) -class TestDeployChanges(base.BaseTestCase): +class TestDeployChanges(base.CLIv1TestCase): - create_env = "env create --name=test --release={0}" - add_node = "--env-id=1 node set --node 1 --role=controller" - deploy_changes = "deploy-changes --env 1" - redeploy_changes = "redeploy-changes --env 1" + cmd_create_env = "env create --name=test --release={0}" + cmd_add_node = "--env-id=1 node set --node 1 --role=controller" + cmd_deploy_changes = "deploy-changes --env 1" + cmd_redeploy_changes = "redeploy-changes --env 1" + + messages_success = [ + "Deploying changes to environment with id=1\n", + "Finished deployment!\n" + ] + message_error = "(No changes to deploy)\n" def setUp(self): super(TestDeployChanges, self).setUp() self.load_data_to_nailgun_server() release_id = self.get_first_deployable_release_id() - self.create_env = self.create_env.format(release_id) - self.run_cli_commands((self.create_env, self.add_node)) + self.cmd_create_env = self.cmd_create_env.format(release_id) + self.run_cli_commands(( + self.cmd_create_env, + self.cmd_add_node + )) def test_deploy_changes(self): - self.run_cli_commands((self.deploy_changes,)) - - def test_no_changes_to_deploy(self): - self.run_cli_commands((self.deploy_changes,)) - self.check_for_stderr(self.deploy_changes, - "(No changes to deploy)\n", - check_errors=False) + self.check_all_in_msg(self.cmd_deploy_changes, + self.messages_success) def test_redeploy_changes(self): - self.run_cli_commands((self.deploy_changes, - self.redeploy_changes)) + self.run_cli_command(self.cmd_deploy_changes) + self.check_for_stderr(self.cmd_deploy_changes, + self.message_error, + check_errors=False) + self.check_all_in_msg(self.cmd_redeploy_changes, + self.messages_success) -class TestDirectoryDoesntExistErrorMessages(base.BaseTestCase): +class TestDirectoryDoesntExistErrorMessages(base.CLIv1TestCase): def test_settings_upload(self): self.check_for_stderr( @@ -520,7 +528,7 @@ class TestDirectoryDoesntExistErrorMessages(base.BaseTestCase): ) -class TestUploadSettings(base.BaseTestCase): +class TestUploadSettings(base.CLIv1TestCase): create_env = "env create --name=test --release={0}" add_node = "--env-id=1 node set --node 1 --role=controller" diff --git a/fuelclient/tests/functional/v2/test_client.py b/fuelclient/tests/functional/v2/test_client.py new file mode 100644 index 0000000..c089180 --- /dev/null +++ b/fuelclient/tests/functional/v2/test_client.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2013-2014 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.tests.functional import base + + +class TestDeployChanges(base.CLIv2TestCase): + + cmd_create_env = "env create -r {0} cluster-test" + cmd_add_node = "env add nodes -e 1 -n 1 -r controller" + cmd_deploy_changes = "env deploy 1" + cmd_redeploy_changes = "env redeploy 1" + + pattern_success = (r"^Deployment task with id (\d{1,}) " + r"for the environment 1 has been started.\n$") + message_error = "(No changes to deploy)\n" + + def setUp(self): + super(TestDeployChanges, self).setUp() + self.load_data_to_nailgun_server() + release_id = self.get_first_deployable_release_id() + self.cmd_create_env = self.cmd_create_env.format(release_id) + self.run_cli_commands(( + self.cmd_create_env, + self.cmd_add_node + )) + + def test_deploy_changes(self): + self.check_for_stdout_by_regexp(self.cmd_deploy_changes, + self.pattern_success) + + def test_redeploy_changes(self): + result = self.check_for_stdout_by_regexp(self.cmd_deploy_changes, + self.pattern_success) + task_id = result.group(1) + self.wait_task_ready(task_id) + + self.check_for_stderr(self.cmd_deploy_changes, + self.message_error, + check_errors=False) + + self.check_for_stdout_by_regexp(self.cmd_redeploy_changes, + self.pattern_success) diff --git a/fuelclient/tests/unit/v2/cli/test_env.py b/fuelclient/tests/unit/v2/cli/test_env.py index 680f67f..83298e5 100644 --- a/fuelclient/tests/unit/v2/cli/test_env.py +++ b/fuelclient/tests/unit/v2/cli/test_env.py @@ -95,6 +95,13 @@ class TestEnvCommand(test_engine.BaseCLITest): self.m_get_client.assert_called_once_with('environment', mock.ANY) self.m_client.deploy_changes.assert_called_once_with(42) + def test_env_redeploy(self): + args = 'env redeploy 42' + self.exec_command(args) + + self.m_get_client.assert_called_once_with('environment', mock.ANY) + self.m_client.redeploy_changes.assert_called_once_with(42) + def test_env_add_nodes(self): args = 'env add nodes -e 42 -n 24 25 -r compute cinder' self.exec_command(args) diff --git a/fuelclient/v1/environment.py b/fuelclient/v1/environment.py index d5678df..5f7ebea 100644 --- a/fuelclient/v1/environment.py +++ b/fuelclient/v1/environment.py @@ -71,6 +71,12 @@ class EnvironmentClient(base_v1.BaseV1Client): return deploy_task.id + def redeploy_changes(self, environment_id): + env = self._entity_wrapper(obj_id=environment_id) + redeploy_task = env.redeploy_changes() + + return redeploy_task.id + def spawn_vms(self, environment_id): env = self._entity_wrapper(obj_id=environment_id) return env.spawn_vms() diff --git a/setup.cfg b/setup.cfg index ac8bd81..6068ed6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,7 @@ fuelclient = env_delete=fuelclient.commands.environment:EnvDelete env_deploy=fuelclient.commands.environment:EnvDeploy env_list=fuelclient.commands.environment:EnvList + env_redeploy=fuelclient.commands.environment:EnvRedeploy env_show=fuelclient.commands.environment:EnvShow env_spawn-vms=fuelclient.commands.environment:EnvSpawnVms env_update=fuelclient.commands.environment:EnvUpdate diff --git a/tox.ini b/tox.ini index 28c02f6..71ec9f8 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ setenv = VIRTUAL_ENV={envdir} # Functional env settings FUEL_WEB_CLONE={env:FUEL_WEB_CLONE:yes} - FUEL_WEB_REPO={env:FUEL_WEB_REPO:https://github.com/stackforge/fuel-web.git} + FUEL_WEB_REPO={env:FUEL_WEB_REPO:https://github.com/openstack/fuel-web.git} FUEL_WEB_ROOT={env:FUEL_WEB_ROOT:/tmp/fuel_web} FETCH_REPO={env:FETCH_REPO:} FETCH_REFSPEC={env:FETCH_REFSPEC:} From 51a6f5f7331c7c0675b4f7c8eaf959c4e99e636c Mon Sep 17 00:00:00 2001 From: Alexander Saprykin Date: Fri, 25 Mar 2016 11:42:04 +0200 Subject: [PATCH 02/11] Add multinode support to openstack config CLIv2 * Option --node accepts list of node ids. * Improve client library, make many parameters optional. * Add unit tests for openstack config library code. Related-Bug: #1557462 Change-Id: Ib6e6ab3cbba6fb28cdec5738267d7aab354dce9f (cherry-picked from 78a01f5aa1a5453f909fae6414860d7957e6d95a) --- fuelclient/commands/openstack_config.py | 16 +- fuelclient/objects/openstack_config.py | 5 + .../unit/v2/cli/test_openstack_config.py | 16 +- .../unit/v2/lib/test_openstack_config.py | 137 ++++++++++++++++++ fuelclient/v1/openstack_config.py | 16 +- 5 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 fuelclient/tests/unit/v2/lib/test_openstack_config.py diff --git a/fuelclient/commands/openstack_config.py b/fuelclient/commands/openstack_config.py index 3d94874..fc10ae3 100644 --- a/fuelclient/commands/openstack_config.py +++ b/fuelclient/commands/openstack_config.py @@ -41,10 +41,10 @@ class OpenstackConfigMixin(object): ) @staticmethod - def add_node_id_arg(parser): + def add_node_ids_arg(parser): parser.add_argument( '-n', '--node', - type=int, default=None, help='Node ID.' + type=int, nargs='+', default=None, help='Node IDs.' ) @staticmethod @@ -81,7 +81,7 @@ class OpenstackConfigList(OpenstackConfigMixin, base.BaseCommand): parser = super(OpenstackConfigList, self).get_parser(prog_name) self.add_env_arg(parser) - self.add_node_id_arg(parser) + self.add_node_ids_arg(parser) self.add_node_role_arg(parser) self.add_deleted_arg(parser) @@ -89,7 +89,7 @@ class OpenstackConfigList(OpenstackConfigMixin, base.BaseCommand): def take_action(self, args): data = self.client.get_filtered( - cluster_id=args.env, node_id=args.node, + cluster_id=args.env, node_ids=args.node, node_role=args.role, is_active=(not args.deleted)) data = data_utils.get_display_data_multi(self.columns, data) @@ -124,7 +124,7 @@ class OpenstackConfigUpload(OpenstackConfigMixin, base.BaseCommand): parser = super(OpenstackConfigUpload, self).get_parser(prog_name) self.add_env_arg(parser) - self.add_node_id_arg(parser) + self.add_node_ids_arg(parser) self.add_node_role_arg(parser) self.add_file_arg(parser) @@ -133,7 +133,7 @@ class OpenstackConfigUpload(OpenstackConfigMixin, base.BaseCommand): def take_action(self, args): config = self.client.upload( path=args.file, cluster_id=args.env, - node_id=args.node, node_role=args.role) + node_ids=args.node, node_role=args.role) msg = "OpenStack configuration with id {0} " \ "uploaded from file '{0}'\n".format(config.id, args.file) @@ -148,7 +148,7 @@ class OpenstackConfigExecute(OpenstackConfigMixin, base.BaseCommand): parser = super(OpenstackConfigExecute, self).get_parser(prog_name) self.add_env_arg(parser) - self.add_node_id_arg(parser) + self.add_node_ids_arg(parser) self.add_node_role_arg(parser) self.add_force_arg(parser) @@ -156,7 +156,7 @@ class OpenstackConfigExecute(OpenstackConfigMixin, base.BaseCommand): def take_action(self, args): self.client.execute( - cluster_id=args.env, node_id=args.node, node_role=args.role, + cluster_id=args.env, node_ids=args.node, node_role=args.role, force=args.force) msg = "OpenStack configuration execution started.\n" diff --git a/fuelclient/objects/openstack_config.py b/fuelclient/objects/openstack_config.py index e1ac3b2..c568354 100644 --- a/fuelclient/objects/openstack_config.py +++ b/fuelclient/objects/openstack_config.py @@ -50,6 +50,11 @@ class OpenstackConfig(BaseObject): def get_filtered_data(cls, **kwargs): url = cls.class_api_path params = cls._prepare_params(kwargs) + + node_ids = params.get('node_ids') + if node_ids is not None: + params['node_ids'] = ','.join([str(n) for n in node_ids]) + return cls.connection.get_request(url, params=params) @classmethod diff --git a/fuelclient/tests/unit/v2/cli/test_openstack_config.py b/fuelclient/tests/unit/v2/cli/test_openstack_config.py index 673a3ef..3365840 100644 --- a/fuelclient/tests/unit/v2/cli/test_openstack_config.py +++ b/fuelclient/tests/unit/v2/cli/test_openstack_config.py @@ -36,21 +36,21 @@ class TestOpenstackConfig(test_engine.BaseCLITest): cmd_line='--env {0} --node {1}'.format(self.CLUSTER_ID, self.NODE_ID), expected_kwargs={'cluster_id': self.CLUSTER_ID, - 'node_id': self.NODE_ID, 'node_role': None, + 'node_ids': [self.NODE_ID], 'node_role': None, 'is_active': True} ) def test_config_list_for_role(self): self._test_config_list( cmd_line='--env {0} --role compute'.format(self.CLUSTER_ID), - expected_kwargs={'cluster_id': self.CLUSTER_ID, 'node_id': None, + expected_kwargs={'cluster_id': self.CLUSTER_ID, 'node_ids': None, 'node_role': 'compute', 'is_active': True} ) def test_config_list_for_cluster(self): self._test_config_list( cmd_line='--env {0}'.format(self.CLUSTER_ID), - expected_kwargs={'cluster_id': self.CLUSTER_ID, 'node_id': None, + expected_kwargs={'cluster_id': self.CLUSTER_ID, 'node_ids': None, 'node_role': None, 'is_active': True} ) @@ -71,7 +71,7 @@ class TestOpenstackConfig(test_engine.BaseCLITest): self.m_get_client.assert_called_once_with('openstack-config', mock.ANY) self.m_client.upload.assert_called_once_with( path='config.yaml', cluster_id=self.CLUSTER_ID, - node_id=self.NODE_ID, node_role=None) + node_ids=[self.NODE_ID], node_role=None) @mock.patch('sys.stderr') def test_config_upload_fail(self, mocked_stderr): @@ -111,8 +111,8 @@ class TestOpenstackConfig(test_engine.BaseCLITest): self.m_get_client.assert_called_once_with('openstack-config', mock.ANY) self.m_client.execute.assert_called_once_with( - cluster_id=self.CLUSTER_ID, node_id=self.NODE_ID, node_role=None, - force=False) + cluster_id=self.CLUSTER_ID, node_ids=[self.NODE_ID], + node_role=None, force=False) def test_config_force_execute(self): cmd = 'openstack-config execute --env {0} --node {1} --force' \ @@ -121,5 +121,5 @@ class TestOpenstackConfig(test_engine.BaseCLITest): self.m_get_client.assert_called_once_with('openstack-config', mock.ANY) self.m_client.execute.assert_called_once_with( - cluster_id=self.CLUSTER_ID, node_id=self.NODE_ID, node_role=None, - force=True) + cluster_id=self.CLUSTER_ID, node_ids=[self.NODE_ID], + node_role=None, force=True) diff --git a/fuelclient/tests/unit/v2/lib/test_openstack_config.py b/fuelclient/tests/unit/v2/lib/test_openstack_config.py new file mode 100644 index 0000000..7780ec8 --- /dev/null +++ b/fuelclient/tests/unit/v2/lib/test_openstack_config.py @@ -0,0 +1,137 @@ +# 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 import utils + + +class TestOpenstackConfigClient(test_api.BaseLibTest): + + def setUp(self): + super(TestOpenstackConfigClient, self).setUp() + + self.version = 'v1' + self.uri = '/api/{version}/openstack-config/'.format( + version=self.version) + + self.client = fuelclient.get_client('openstack-config', self.version) + + def test_config_list_for_cluster(self): + cluster_id = 1 + fake_configs = [ + utils.get_fake_openstack_config(cluster_id=cluster_id) + ] + + uri = self.uri + '?cluster_id={0}&is_active=True'.format(cluster_id) + m_get = self.m_request.get(uri, complete_qs=True, json=fake_configs) + data = self.client.get_filtered(cluster_id=1) + + self.assertTrue(m_get.called) + self.assertEqual(data[0]['cluster_id'], cluster_id) + + def test_config_list_for_node(self): + cluster_id = 1 + fake_configs = [ + utils.get_fake_openstack_config( + cluster_id=cluster_id, node_id=22), + ] + + uri = self.uri + '?cluster_id={0}&node_ids=22' \ + '&is_active=True'.format(cluster_id) + m_get = self.m_request.get(uri, complete_qs=True, json=fake_configs) + data = self.client.get_filtered(cluster_id=1, node_ids=[22]) + + self.assertTrue(m_get.called) + self.assertEqual(data[0]['cluster_id'], cluster_id) + + def test_config_list_for_multinode(self): + cluster_id = 1 + fake_configs = [ + utils.get_fake_openstack_config( + cluster_id=cluster_id, node_id=22), + utils.get_fake_openstack_config( + cluster_id=cluster_id, node_id=44), + ] + + uri = self.uri + '?cluster_id={0}&node_ids=22,44' \ + '&is_active=True'.format(cluster_id) + m_get = self.m_request.get(uri, complete_qs=True, json=fake_configs) + data = self.client.get_filtered(cluster_id=1, node_ids=[22, 44]) + + self.assertTrue(m_get.called) + self.assertEqual(data[0]['cluster_id'], cluster_id) + + def test_config_download(self): + config_id = 42 + uri = self.uri + '{0}/'.format(42) + fake_config = utils.get_fake_openstack_config(id=config_id) + + m_get = self.m_request.get(uri, json=fake_config) + + m_open = mock.mock_open() + with mock.patch('fuelclient.cli.serializers.open', + m_open, create=True): + self.client.download(config_id, '/path/to/config') + + self.assertTrue(m_get.called) + written_yaml = yaml.safe_load(m_open().write.mock_calls[0][1][0]) + self.assertEqual(written_yaml, + {'configuration': fake_config['configuration']}) + + def test_config_upload(self): + cluster_id = 1 + fake_config = utils.get_fake_openstack_config( + cluster_id=cluster_id) + + m_post = self.m_request.post(self.uri, json=fake_config) + + m_open = mock.mock_open(read_data=yaml.safe_dump({ + 'configuration': fake_config['configuration'] + })) + with mock.patch( + 'fuelclient.cli.serializers.open', m_open, create=True), \ + mock.patch('os.path.exists', mock.Mock(return_value=True)): + self.client.upload('/path/to/config', cluster_id) + + self.assertTrue(m_post.called) + body = m_post.last_request.json() + self.assertEqual(body['cluster_id'], cluster_id) + self.assertNotIn('node_ids', body) + self.assertNotIn('node_role', body) + + def test_config_upload_multinode(self): + cluster_id = 1 + fake_config = utils.get_fake_openstack_config( + cluster_id=cluster_id) + + m_post = self.m_request.post(self.uri, json={'id': 42}) + + m_open = mock.mock_open(read_data=yaml.safe_dump({ + 'configuration': fake_config['configuration'] + })) + with mock.patch( + 'fuelclient.cli.serializers.open', m_open, create=True), \ + mock.patch('os.path.exists', mock.Mock(return_value=True)): + self.client.upload( + '/path/to/config', cluster_id, node_ids=[42, 44]) + + self.assertTrue(m_post.called) + body = m_post.last_request.json() + self.assertEqual(body['cluster_id'], cluster_id) + self.assertEqual(body['node_ids'], [42, 44]) + self.assertNotIn('node_role', body) diff --git a/fuelclient/v1/openstack_config.py b/fuelclient/v1/openstack_config.py index 7fb13d9..0bd589a 100644 --- a/fuelclient/v1/openstack_config.py +++ b/fuelclient/v1/openstack_config.py @@ -20,11 +20,11 @@ class OpenstackConfigClient(base_v1.BaseV1Client): _entity_wrapper = objects.OpenstackConfig - def upload(self, path, cluster_id, node_id=None, node_role=None): + def upload(self, path, cluster_id, node_ids=None, node_role=None): data = self._entity_wrapper.read_file(path) return self._entity_wrapper.create( cluster_id=cluster_id, configuration=data['configuration'], - node_id=node_id, node_role=node_role) + node_ids=node_ids, node_role=node_role) def download(self, config_id, path): config = self._entity_wrapper(config_id) @@ -33,15 +33,17 @@ class OpenstackConfigClient(base_v1.BaseV1Client): return path - def execute(self, config_id, cluster_id, node_id, node_role, force=False): + def execute(self, cluster_id, config_id=None, node_ids=None, + node_role=None, force=False): return self._entity_wrapper.execute( - config_id=config_id, cluster_id=cluster_id, node_id=node_id, + cluster_id=cluster_id, config_id=config_id, node_ids=node_ids, node_role=node_role, force=force) - def get_filtered(self, cluster_id, node_id, node_role, is_active=True): + def get_filtered(self, cluster_id, node_ids=None, + node_role=None, is_active=True): return self._entity_wrapper.get_filtered_data( - cluster_id=cluster_id, node_id=node_id, node_role=node_role, - is_active=is_active) + cluster_id=cluster_id, node_ids=node_ids, + node_role=node_role, is_active=is_active) def get_client(): From 8bea4a1c56e67a826f66c642678ef3a429ee9c8a Mon Sep 17 00:00:00 2001 From: Alexander Saprykin Date: Thu, 24 Mar 2016 10:50:31 +0200 Subject: [PATCH 03/11] Add support of bulk operations for openstack-config * Add multiple node filter for openstack-config list * Allow upload config for multiple nodes * Allow execute config for multiple nodes Related-Bug: #1557462 Change-Id: I3d964be1dcf1745ab1634b946850c3294fa799d5 (cherry-picked from 54439bbaa346a84e097986b28bed8e076ce292a5) --- fuelclient/cli/actions/openstack_config.py | 19 ++++---- fuelclient/objects/openstack_config.py | 5 +++ .../tests/unit/v1/test_openstack_config.py | 44 ++++++++++++++++++- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/fuelclient/cli/actions/openstack_config.py b/fuelclient/cli/actions/openstack_config.py index 65e77b4..7ed1c11 100644 --- a/fuelclient/cli/actions/openstack_config.py +++ b/fuelclient/cli/actions/openstack_config.py @@ -32,7 +32,7 @@ class OpenstackConfigAction(Action): self.args = ( Args.get_env_arg(), Args.get_file_arg("Openstack configuration file"), - Args.get_single_node_arg("Node ID"), + Args.get_node_arg("Node IDs list"), Args.get_single_role_arg("Node role"), Args.get_config_id_arg("Openstack config ID"), Args.get_deleted_arg("Get deleted configurations"), @@ -60,7 +60,7 @@ class OpenstackConfigAction(Action): def list(self, params): """List all available configurations: fuel openstack-config --list --env 1 - fuel openstack-config --list --env 1 --node 1 + fuel openstack-config --list --env 1 --node 1[,2,3,...] fuel openstack-config --list --env 1 --deleted """ filters = {'cluster_id': params.env} @@ -69,7 +69,7 @@ class OpenstackConfigAction(Action): filters['is_active'] = int(not params.deleted) if 'node' in params: - filters['node_id'] = params.node + filters['node_ids'] = params.node if 'role' in params: filters['node_role'] = params.role @@ -99,18 +99,19 @@ class OpenstackConfigAction(Action): def upload(self, params): """Upload new configuration from file: fuel openstack-config --upload --env 1 --file config.yaml - fuel openstack-config --upload --env 1 --node 1 --file config.yaml + fuel openstack-config --upload --env 1 --node 1[,2,3,...] + --file config.yaml fuel openstack-config --upload --env 1 --role controller --file config.yaml """ - node_id = getattr(params, 'node', None) + node_ids = getattr(params, 'node', None) node_role = getattr(params, 'role', None) data = OpenstackConfig.read_file(params.file) config = OpenstackConfig.create( cluster_id=params.env, configuration=data['configuration'], - node_id=node_id, node_role=node_role) + node_ids=node_ids, node_role=node_role) print("Openstack configuration with id {0} " "has been uploaded from file '{1}'" "".format(config.id, params.file)) @@ -130,15 +131,15 @@ class OpenstackConfigAction(Action): def execute(self, params): """Deploy configuration: fuel openstack-config --execute --env 1 - fuel openstack-config --execute --env 1 --node 1 + fuel openstack-config --execute --env 1 --node 1[,2,3,...] fuel openstack-config --execute --env 1 --role controller fuel openstack-config --execute --env 1 --force """ - node_id = getattr(params, 'node', None) + node_ids = getattr(params, 'node', None) node_role = getattr(params, 'role', None) force = getattr(params, 'force', False) task_result = OpenstackConfig.execute( - cluster_id=params.env, node_id=node_id, + cluster_id=params.env, node_ids=node_ids, node_role=node_role, force=force) if task_result['status'] == 'error': print( diff --git a/fuelclient/objects/openstack_config.py b/fuelclient/objects/openstack_config.py index e1ac3b2..c568354 100644 --- a/fuelclient/objects/openstack_config.py +++ b/fuelclient/objects/openstack_config.py @@ -50,6 +50,11 @@ class OpenstackConfig(BaseObject): def get_filtered_data(cls, **kwargs): url = cls.class_api_path params = cls._prepare_params(kwargs) + + node_ids = params.get('node_ids') + if node_ids is not None: + params['node_ids'] = ','.join([str(n) for n in node_ids]) + return cls.connection.get_request(url, params=params) @classmethod diff --git a/fuelclient/tests/unit/v1/test_openstack_config.py b/fuelclient/tests/unit/v1/test_openstack_config.py index 0365661..f4c500e 100644 --- a/fuelclient/tests/unit/v1/test_openstack_config.py +++ b/fuelclient/tests/unit/v1/test_openstack_config.py @@ -72,6 +72,27 @@ class TestOpenstackConfigActions(base.UnitTestCase): '--upload', '--file', 'config.yaml']) self.assertTrue(m_post.called) + req = json.loads(m_post.last_request.text) + self.assertEqual(req['cluster_id'], 1) + + def test_config_upload_multinode(self): + m_post = self.m_request.post( + '/api/v1/openstack-config/', json=self.config) + + m_open = mock.mock_open(read_data=yaml.safe_dump( + {'configuration': self.config['configuration']})) + with mock.patch('fuelclient.cli.serializers.open', + m_open, create=True): + with mock.patch('fuelclient.objects.openstack_config.os'): + self.execute(['fuel', 'openstack-config', '--env', '1', + '--node', '1,2,3', + '--upload', '--file', 'config.yaml']) + self.assertTrue(m_post.called) + + req = json.loads(m_post.last_request.text) + self.assertEqual(req['node_ids'], [1, 2, 3]) + self.assertEqual(req['cluster_id'], 1) + @mock.patch('sys.stderr') def test_config_upload_fail(self, mocked_stderr): self.assertRaises( @@ -107,13 +128,23 @@ class TestOpenstackConfigActions(base.UnitTestCase): self.assertTrue(m_get.called) m_get = self.m_request.get( - '/api/v1/openstack-config/?cluster_id=84&node_id=42', json=[ + '/api/v1/openstack-config/?cluster_id=84&node_ids=42', json=[ utils.get_fake_openstack_config(id=1, cluster_id=32), ]) self.execute(['fuel', 'openstack-config', '--env', '84', '--node', '42', '--list']) self.assertTrue(m_get.called) + def test_config_list_multinode(self): + m_get = self.m_request.get( + '/api/v1/openstack-config/?cluster_id=84&node_ids=1,2,3', + json=[utils.get_fake_openstack_config( + id=1, cluster_id=32, node_id=1)]) + + self.execute(['fuel', 'openstack-config', '--env', '84', + '--node', '1,2,3', '--list']) + self.assertTrue(m_get.called) + @mock.patch('sys.stderr') def test_config_list_fail(self, m_stderr): self.assertRaises( @@ -137,6 +168,17 @@ class TestOpenstackConfigActions(base.UnitTestCase): self.assertEqual({"cluster_id": 42, "force": False}, json.loads(m_put.last_request.text)) + def test_config_execute_multinode(self): + m_put = self.m_request.put('/api/v1/openstack-config/execute/', + json={'status': 'ready'}) + + self.execute(['fuel', 'openstack-config', '--env', '42', + '--node', '1,2,3', '--execute']) + self.assertTrue(m_put.called) + self.assertEqual( + {"cluster_id": 42, "force": False, "node_ids": [1, 2, 3]}, + json.loads(m_put.last_request.text)) + def test_config_force_execute(self): m_put = self.m_request.put('/api/v1/openstack-config/execute/', json={'status': 'ready'}) From 6844690c0f50385509eca3ccc0bf800609c66606 Mon Sep 17 00:00:00 2001 From: Ilya Kutukov Date: Wed, 30 Mar 2016 14:18:18 +0300 Subject: [PATCH 04/11] 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 (cherry picked from commit 7a9cd8abec0a238eed4d7234d5d58e83e144fcad) --- 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 7bc2532..bbd03e7 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 From ae75ccd49586d091e4b10d1155ce1af281717a9d Mon Sep 17 00:00:00 2001 From: Ilya Kutukov Date: Tue, 5 Apr 2016 21:15:10 +0300 Subject: [PATCH 05/11] Add custom graph download and list 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 download --env env_id --all [--type graph_type] [--file cluster_graph.yaml] * fuel2 graph download --env env_id --cluster [--type graph_type] [--file cluster_graph.yaml] * fuel2 graph download --env env_id --plugins [--type graph_type] [--file plugins_graph.yaml] * fuel2 graph download --env env_id --release [--type graph_type] [--file release_graph.yaml] --type is optional and ‘default’ graph will be downloaded in no type is defined. * fuel2 graph list --env env_id Change-Id: Iba9255f2b201ffc54e81e0f15ba7b9e415ef63ba Closes-Bug: #1563851 DocImpact (cherry picked from commit 85311221ca32c190c963f32d1ffb4cc092f96ec6) --- fuelclient/commands/graph.py | 132 ++++++++++++++++++ .../unit/v2/cli/test_deployment_graph.py | 42 ++++++ .../unit/v2/lib/test_deployment_graph.py | 16 +++ fuelclient/v1/graph.py | 60 ++++++++ setup.cfg | 2 + 5 files changed, 252 insertions(+) diff --git a/fuelclient/commands/graph.py b/fuelclient/commands/graph.py index ef0a86f..32eacac 100644 --- a/fuelclient/commands/graph.py +++ b/fuelclient/commands/graph.py @@ -19,6 +19,7 @@ import os from fuelclient.cli import error from fuelclient.cli.serializers import Serializer from fuelclient.commands import base +from fuelclient.common import data_utils class FileMethodsMixin(object): @@ -148,3 +149,134 @@ class GraphExecute(base.BaseCommand): self.app.stdout.write( "Deployment was executed\n" ) + + +class GraphDownload(base.BaseCommand): + """Download deployment graph configuration.""" + entity_name = 'graph' + + def get_parser(self, prog_name): + parser = super(GraphDownload, self).get_parser(prog_name) + tasks_level = parser.add_mutually_exclusive_group() + parser.add_argument('-e', + '--env', + type=int, + required=True, + help='Id of the environment') + + tasks_level.add_argument('-a', + '--all', + action="store_true", + required=False, + default=False, + help='Download merged graph for the ' + 'environment') + tasks_level.add_argument('-c', + '--cluster', + action="store_true", + required=False, + default=False, + help='Download cluster-specific tasks') + tasks_level.add_argument('-p', + '--plugins', + action="store_true", + required=False, + default=False, + help='Download plugins-specific tasks') + tasks_level.add_argument('-r', + '--release', + action="store_true", + required=False, + default=False, + help='Download release-specific tasks') + + parser.add_argument('-t', + '--type', + type=str, + default=None, + required=False, + help='Graph type string') + parser.add_argument('-f', + '--file', + type=str, + required=False, + default=None, + help='YAML file that contains tasks data.') + return parser + + @classmethod + def get_default_tasks_data_path(cls): + return os.path.join( + os.path.abspath(os.curdir), + "cluster_graph" + ) + + @classmethod + def write_tasks_to_file(cls, tasks_data, serializer=None, file_path=None): + serializer = serializer or Serializer() + if file_path: + return serializer.write_to_full_path( + file_path, + tasks_data + ) + else: + return serializer.write_to_path( + cls.get_default_tasks_data_path(), + tasks_data + ) + + def take_action(self, args): + tasks_data = [] + for tasks_level_name in ('all', 'cluster', 'release', 'plugins'): + if getattr(args, tasks_level_name): + tasks_data = self.client.download( + env_id=args.env, + level=tasks_level_name, + graph_type=args.type + ) + break + + # write to file + graph_data_file_path = self.write_tasks_to_file( + tasks_data=tasks_data, + serializer=Serializer(), + file_path=args.file) + + self.app.stdout.write( + "Tasks were downloaded to {0}\n".format(graph_data_file_path) + ) + + +class GraphList(base.BaseListCommand): + """Upload deployment graph configuration.""" + entity_name = 'graph' + columns = ("id", + "name", + "tasks", + "relations") + + def get_parser(self, prog_name): + parser = super(GraphList, self).get_parser(prog_name) + parser.add_argument('-e', + '--env', + type=int, + required=True, + help='Id of the environment') + return parser + + def take_action(self, parsed_args): + data = self.client.list( + env_id=parsed_args.env + ) + # format fields + for d in data: + d['relations'] = "\n".join( + 'as "{type}" to {model}(ID={model_id})' + .format(**r) for r in d['relations'] + ) + d['tasks'] = ', '.join(sorted(t['id'] for t in d['tasks'])) + data = data_utils.get_display_data_multi(self.columns, data) + scolumn_ids = [self.columns.index(col) + for col in parsed_args.sort_columns] + data.sort(key=lambda x: [x[scolumn_id] for scolumn_id in scolumn_ids]) + return self.columns, data diff --git a/fuelclient/tests/unit/v2/cli/test_deployment_graph.py b/fuelclient/tests/unit/v2/cli/test_deployment_graph.py index 512495a..1c5e272 100644 --- a/fuelclient/tests/unit/v2/cli/test_deployment_graph.py +++ b/fuelclient/tests/unit/v2/cli/test_deployment_graph.py @@ -15,6 +15,7 @@ # under the License. import mock +import six import yaml from fuelclient.tests.unit.v2.cli import test_engine @@ -86,3 +87,44 @@ class TestGraphActions(test_engine.BaseCLITest): nodes=[1, 2, 3] ) ) + + def test_download(self): + self._test_cmd( + 'download', + '--env 1 --all --file existing_graph.yaml --type custom_graph', + dict( + env_id=1, + level='all', + graph_type='custom_graph' + ) + ) + + def test_list(self): + with mock.patch('sys.stdout', new=six.moves.cStringIO()) as m_stdout: + self.m_get_client.reset_mock() + self.m_client.get_filtered.reset_mock() + self.m_client.list.return_value = [ + { + 'name': 'updated-graph-name', + 'tasks': [{ + 'id': 'test-task2', + 'type': 'puppet', + 'task_name': 'test-task2', + 'version': '2.0.0' + }], + 'relations': [{ + 'model': 'cluster', + 'model_id': 370, + 'type': 'custom-graph' + }], + 'id': 1 + } + ] + self.exec_command('graph list --env 1') + self.m_get_client.assert_called_once_with('graph', mock.ANY) + self.m_client.list.assert_called_once_with(env_id=1) + + self.assertIn('1', m_stdout.getvalue()) + self.assertIn('updated-graph-name', m_stdout.getvalue()) + self.assertIn('custom-graph', m_stdout.getvalue()) + self.assertIn('test-task2', m_stdout.getvalue()) diff --git a/fuelclient/tests/unit/v2/lib/test_deployment_graph.py b/fuelclient/tests/unit/v2/lib/test_deployment_graph.py index 5eb71cf..92f0554 100644 --- a/fuelclient/tests/unit/v2/lib/test_deployment_graph.py +++ b/fuelclient/tests/unit/v2/lib/test_deployment_graph.py @@ -115,3 +115,19 @@ class TestDeploymentGraphFacade(test_api.BaseLibTest): nodes=[1, 2, 3], graph_type="custom_graph") self.assertTrue(matcher_put.called) + + def test_graphs_list(self): + matcher_get = self.m_request.get( + '/api/v1/clusters/1/deployment_graphs/', + json=[] + ) + self.client.list(1) + self.assertTrue(matcher_get.called) + + def test_graphs_download(self): + matcher_get = self.m_request.get( + '/api/v1/clusters/1/deployment_tasks/?graph_type=custom_graph', + json=[] + ) + self.client.download(env_id=1, level='all', graph_type='custom_graph') + self.assertTrue(matcher_get.called) diff --git a/fuelclient/v1/graph.py b/fuelclient/v1/graph.py index 3d185a1..fa203f0 100644 --- a/fuelclient/v1/graph.py +++ b/fuelclient/v1/graph.py @@ -31,6 +31,15 @@ class GraphClient(base_v1.BaseV1Client): cluster_deploy_api_path = "clusters/{env_id}/deploy/" + merged_cluster_tasks_api_path = "clusters/{env_id}/deployment_tasks" \ + "/?graph_type={graph_type}" + + merged_plugins_tasks_api_path = "clusters/{env_id}/deployment_tasks" \ + "/plugins/?graph_type={graph_type}" + + cluster_release_tasks_api_path = "clusters/{env_id}/deployment_tasks" \ + "/release/?graph_type={graph_type}" + @classmethod def update_graph_for_model( cls, data, related_model, related_model_id, graph_type=None): @@ -92,6 +101,57 @@ class GraphClient(base_v1.BaseV1Client): deploy_data = APIClient.put_request(url, {}) return objects.DeployTask.init_with_data(deploy_data) + # download + @classmethod + def get_merged_cluster_tasks(cls, env_id, graph_type=None): + return APIClient.get_request( + cls.merged_cluster_tasks_api_path.format( + env_id=env_id, + graph_type=graph_type or "")) + + @classmethod + def get_merged_plugins_tasks(cls, env_id, graph_type=None): + return APIClient.get_request( + cls.merged_plugins_tasks_api_path.format( + env_id=env_id, + graph_type=graph_type or "")) + + @classmethod + def get_release_tasks_for_cluster(cls, env_id, graph_type=None): + return APIClient.get_request( + cls.merged_plugins_tasks_api_path.format( + env_id=env_id, + graph_type=graph_type or "")) + + def download(self, env_id, level, graph_type): + tasks_levels = { + 'all': lambda: self.get_merged_cluster_tasks( + env_id=env_id, graph_type=graph_type), + + 'cluster': lambda: self.get_graph_for_model( + related_model='clusters', + related_model_id=env_id, + graph_type=graph_type).get('tasks', []), + + 'plugins': lambda: self.get_merged_plugins_tasks( + env_id=env_id, + graph_type=graph_type), + + 'release': lambda: self.get_release_tasks_for_cluster( + env_id=env_id, + graph_type=graph_type) + } + return tasks_levels[level]() + + # list + @classmethod + def list(cls, env_id): + # todo(ikutukov): extend lists to support all models + return APIClient.get_request( + cls.related_graphs_list_api_path.format( + related_model='clusters', + related_model_id=env_id)) + def get_client(): return GraphClient() diff --git a/setup.cfg b/setup.cfg index bbd03e7..2265948 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,7 +39,9 @@ fuelclient = env_spawn-vms=fuelclient.commands.environment:EnvSpawnVms env_update=fuelclient.commands.environment:EnvUpdate fuel-version=fuelclient.commands.fuelversion:FuelVersion + graph_download=fuelclient.commands.graph:GraphDownload graph_execute=fuelclient.commands.graph:GraphExecute + graph_list=fuelclient.commands.graph:GraphList graph_upload=fuelclient.commands.graph:GraphUpload network-group_create=fuelclient.commands.network_group:NetworkGroupCreate network-group_delete=fuelclient.commands.network_group:NetworkGroupDelete From 70b05a140b6fc4d126e3250e5ad4dd6f64f3f15c Mon Sep 17 00:00:00 2001 From: Roman Prykhodchenko Date: Fri, 8 Apr 2016 17:54:26 +0200 Subject: [PATCH 06/11] Adapt tests for new re-deployment logic Deployment logic was changed in [1] to start deployment tasks even if there are no changes to be deployed. This patch updates functional tests accordingly. 1. I4a6f5f37161e4290050ec4926cf029cd7af566e4 Change-Id: Id8813314f5f8f47a100cbf0975ab60a488164839 Partial-bug: #1565885 (cherry picked from commit 446b8874241657b4715094fc0b8d75f6cfc36deb) --- fuelclient/tests/functional/v1/test_client.py | 4 ---- fuelclient/tests/functional/v2/test_client.py | 5 ----- 2 files changed, 9 deletions(-) diff --git a/fuelclient/tests/functional/v1/test_client.py b/fuelclient/tests/functional/v1/test_client.py index e2a90fd..a7affe9 100644 --- a/fuelclient/tests/functional/v1/test_client.py +++ b/fuelclient/tests/functional/v1/test_client.py @@ -426,7 +426,6 @@ class TestDeployChanges(base.CLIv1TestCase): "Deploying changes to environment with id=1\n", "Finished deployment!\n" ] - message_error = "(No changes to deploy)\n" def setUp(self): super(TestDeployChanges, self).setUp() @@ -444,9 +443,6 @@ class TestDeployChanges(base.CLIv1TestCase): def test_redeploy_changes(self): self.run_cli_command(self.cmd_deploy_changes) - self.check_for_stderr(self.cmd_deploy_changes, - self.message_error, - check_errors=False) self.check_all_in_msg(self.cmd_redeploy_changes, self.messages_success) diff --git a/fuelclient/tests/functional/v2/test_client.py b/fuelclient/tests/functional/v2/test_client.py index c089180..3c14c21 100644 --- a/fuelclient/tests/functional/v2/test_client.py +++ b/fuelclient/tests/functional/v2/test_client.py @@ -26,7 +26,6 @@ class TestDeployChanges(base.CLIv2TestCase): pattern_success = (r"^Deployment task with id (\d{1,}) " r"for the environment 1 has been started.\n$") - message_error = "(No changes to deploy)\n" def setUp(self): super(TestDeployChanges, self).setUp() @@ -48,9 +47,5 @@ class TestDeployChanges(base.CLIv2TestCase): task_id = result.group(1) self.wait_task_ready(task_id) - self.check_for_stderr(self.cmd_deploy_changes, - self.message_error, - check_errors=False) - self.check_for_stdout_by_regexp(self.cmd_redeploy_changes, self.pattern_success) From 4816aefa49e4d0ca39cb3d6bf38617a351c0ef79 Mon Sep 17 00:00:00 2001 From: Bulat Gaifullin Date: Thu, 14 Apr 2016 11:52:05 +0300 Subject: [PATCH 07/11] Fixed building patch for saving deployment info Since 9.0 the deployment info is serialized only per node. Change-Id: I15dc8d8d7685043b33ae891380dc1de2002e1139 Closes-Bug: 1570234 (cherry picked from commit 499ec3235433cbdefabcc86930d08ec94426c9ea) --- fuelclient/objects/environment.py | 13 +- fuelclient/tests/functional/v1/test_client.py | 12 +- .../tests/unit/common/test_environments.py | 127 ++++++++++++++++++ 3 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 fuelclient/tests/unit/common/test_environments.py diff --git a/fuelclient/objects/environment.py b/fuelclient/objects/environment.py index 9202e8b..fc3745b 100644 --- a/fuelclient/objects/environment.py +++ b/fuelclient/objects/environment.py @@ -356,13 +356,20 @@ class Environment(BaseObject): (serializer or self.serializer).write_to_path( engine_file_path, facts["engine"]) facts = facts["nodes"] - name_template = u"{name}" + + def name_builder(fact): + return fact['name'] else: - name_template = "{role}_{uid}" + def name_builder(fact): + if 'role' in fact: + # from 9.0 the deployment info is serialized only per node + return "{role}_{uid}".format(**fact) + return fact['uid'] + for _fact in facts: fact_path = os.path.join( dir_name, - name_template.format(**_fact) + name_builder(_fact) ) (serializer or self.serializer).write_to_path(fact_path, _fact) return dir_name diff --git a/fuelclient/tests/functional/v1/test_client.py b/fuelclient/tests/functional/v1/test_client.py index a7affe9..a1fc45c 100644 --- a/fuelclient/tests/functional/v1/test_client.py +++ b/fuelclient/tests/functional/v1/test_client.py @@ -322,9 +322,9 @@ class TestFiles(base.CLIv1TestCase): "--env 1 deployment --default", ( "deployment_1", - "deployment_1/primary-controller_1.yaml", - "deployment_1/compute_2.yaml", - "deployment_1/compute_3.yaml" + "deployment_1/1.yaml", + "deployment_1/2.yaml", + "deployment_1/3.yaml" ) ), ( @@ -340,9 +340,9 @@ class TestFiles(base.CLIv1TestCase): ( "--env 1 deployment --default --json", ( - "deployment_1/primary-controller_1.json", - "deployment_1/compute_2.json", - "deployment_1/compute_3.json" + "deployment_1/1.json", + "deployment_1/2.json", + "deployment_1/3.json" ) ), ( diff --git a/fuelclient/tests/unit/common/test_environments.py b/fuelclient/tests/unit/common/test_environments.py new file mode 100644 index 0000000..da31963 --- /dev/null +++ b/fuelclient/tests/unit/common/test_environments.py @@ -0,0 +1,127 @@ +# -*- 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 os + +from fuelclient import objects + +from fuelclient.tests.unit.v1 import base + + +class TestEnvironmentObject(base.UnitTestCase): + + def setUp(self): + super(TestEnvironmentObject, self).setUp() + self.env_object = objects.Environment(1) + + def _setup_os_mock(self, os_mock): + os_mock.path.exists.return_value = False + os_mock.path.join = os.path.join + os_mock.path.abspath = lambda x: x + + @mock.patch("fuelclient.objects.environment.os") + def test_write_facts_to_dir_for_legacy_envs(self, os_mock): + facts = [ + { + "uid": "1", + "role": "controller", + "data": "data1" + }, + { + "uid": "2", + "role": "compute", + "data": "data2" + }, + ] + + self._setup_os_mock(os_mock) + serializer = mock.MagicMock() + + self.env_object.write_facts_to_dir( + "deployment", facts, serializer=serializer + ) + + serializer.write_to_path.assert_has_calls( + [ + mock.call("./deployment_1/controller_1", facts[0]), + mock.call("./deployment_1/compute_2", facts[1]) + ] + ) + + @mock.patch("fuelclient.objects.environment.os") + def test_write_facts_to_dir_for_new_envs(self, os_mock): + facts = [ + { + "uid": "1", + "roles": ["controller"], + "data": "data1" + }, + { + "uid": "2", + "roles": ["compute"], + "data": "data2" + }, + ] + + self._setup_os_mock(os_mock) + serializer = mock.MagicMock() + + self.env_object.write_facts_to_dir( + "deployment", facts, serializer=serializer + ) + + serializer.write_to_path.assert_has_calls( + [ + mock.call("./deployment_1/1", facts[0]), + mock.call("./deployment_1/2", facts[1]) + ] + ) + + @mock.patch("fuelclient.objects.environment.os") + def test_write_facts_to_dir_if_facts_is_dict(self, os_mock): + facts = { + "engine": "test_engine", + "nodes": [ + { + "uid": "1", + "name": "node-1", + "roles": ["controller"], + "data": "data1" + }, + { + "uid": "2", + "name": "node-2", + "roles": ["compute"], + "data": "data2" + }, + ] + } + + self._setup_os_mock(os_mock) + serializer = mock.MagicMock() + + self.env_object.write_facts_to_dir( + "deployment", facts, serializer=serializer + ) + + serializer.write_to_path.assert_has_calls( + [ + mock.call("./deployment_1/engine", facts['engine']), + mock.call("./deployment_1/node-1", facts['nodes'][0]), + mock.call("./deployment_1/node-2", facts['nodes'][1]) + ] + ) From a2388bc910b72a18c8389b5879d3f95a8876ff50 Mon Sep 17 00:00:00 2001 From: Sergey Kulanov Date: Thu, 14 Apr 2016 12:13:08 +0300 Subject: [PATCH 08/11] Align git branches for tests We need to use stable/mitaka branch both in python-fuelclient and fuel-web Switching to full repo cloning since for now we need checkout to other branches Change-Id: Ib3018bcda4e9e89a1ca9f80447883896f4db3b8c Related-bug: #1558589 --- tools/prepare_nailgun.sh | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/prepare_nailgun.sh b/tools/prepare_nailgun.sh index b509e1b..3a9c3d9 100644 --- a/tools/prepare_nailgun.sh +++ b/tools/prepare_nailgun.sh @@ -122,7 +122,7 @@ obtain_nailgun() { err "Obtaining Nailgun with the revision $FUEL_COMMIT" if [[ "$FUEL_WEB_CLONE" == "yes" ]]; then - git clone --depth 1 $FUEL_WEB_REPO $FUEL_WEB_ROOT || \ + git clone "${FUEL_WEB_REPO}" "${FUEL_WEB_ROOT}" || \ { err "Failed to clone Nailgun"; exit 1; } fi diff --git a/tox.ini b/tox.ini index 70da997..4c8cbfc 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,7 @@ setenv = VIRTUAL_ENV={envdir} FUEL_WEB_ROOT={env:FUEL_WEB_ROOT:/tmp/fuel_web} FETCH_REPO={env:FETCH_REPO:} FETCH_REFSPEC={env:FETCH_REFSPEC:} - FUEL_COMMIT={env:FUEL_COMMIT:master} + FUEL_COMMIT={env:FUEL_COMMIT:stable/mitaka} NAILGUN_ROOT={env:FUEL_WEB_ROOT:/tmp/fuel_web}/nailgun # Nailgun server parameters From a4000b98db421c2571df003637153c6b776ebf25 Mon Sep 17 00:00:00 2001 From: "Vladimir Sharshov (warpc)" Date: Tue, 29 Mar 2016 15:38:47 +0300 Subject: [PATCH 09/11] Add support for info actions for task fuel2 task deployment-info download 1 --file deployment-info.yaml fuel2 task settings download 1 --file settings.yaml fuel2 task network-configuration download 1 --file networks.yaml Closes-Bug: #1566462 DocImpact Change-Id: If6fdeaf68c5dd4d8b3330e63f02205d425ae5021 (cherry picked from commit 3b1e95e235363e879f6b06aaad9e49420b573e69) --- fuelclient/__init__.py | 3 + fuelclient/commands/task.py | 133 ++++++++++++++++++ fuelclient/objects/task.py | 29 ++++ fuelclient/tests/unit/v2/cli/test_task.py | 39 +++++ .../unit/v2/lib/test_task_additional_info.py | 58 ++++++++ fuelclient/tests/utils/__init__.py | 9 ++ .../tests/utils/fake_additional_info.py | 90 ++++++++++++ fuelclient/v1/__init__.py | 8 +- fuelclient/v1/cluster_settings.py | 31 ++++ fuelclient/v1/deployment_info.py | 31 ++++ fuelclient/v1/network_configuration.py | 31 ++++ setup.cfg | 3 + 12 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 fuelclient/tests/unit/v2/lib/test_task_additional_info.py create mode 100644 fuelclient/tests/utils/fake_additional_info.py create mode 100644 fuelclient/v1/cluster_settings.py create mode 100644 fuelclient/v1/deployment_info.py create mode 100644 fuelclient/v1/network_configuration.py diff --git a/fuelclient/__init__.py b/fuelclient/__init__.py index 2ffc7a7..9f90572 100644 --- a/fuelclient/__init__.py +++ b/fuelclient/__init__.py @@ -48,10 +48,13 @@ def get_client(resource, version='v1'): version_map = { 'v1': { + 'cluster-settings': v1.cluster_settings, 'deployment_history': v1.deployment_history, + 'deployment-info': v1.deployment_info, 'environment': v1.environment, 'fuel-version': v1.fuelversion, 'graph': v1.graph, + 'network-configuration': v1.network_configuration, 'network-group': v1.network_group, 'node': v1.node, 'openstack-config': v1.openstack_config, diff --git a/fuelclient/commands/task.py b/fuelclient/commands/task.py index 8988403..fa5405b 100644 --- a/fuelclient/commands/task.py +++ b/fuelclient/commands/task.py @@ -12,6 +12,9 @@ # License for the specific language governing permissions and limitations # under the License. +import os + +from fuelclient.cli.serializers import Serializer from fuelclient.commands import base from fuelclient.common import data_utils @@ -19,6 +22,97 @@ from fuelclient.common import data_utils class TaskMixIn(object): entity_name = 'task' + @staticmethod + def add_file_arg(parser): + parser.add_argument( + '-f', + '--file', + required=False, + type=str, + help='YAML file that contains network configuration.' + ) + + @classmethod + def write_info_to_file(cls, info_type, data, transaction_id, + serializer=None, file_path=None): + """Write additional info to the given path. + + :param info_type: deployment_info | cluster_settings | + network_configuration + :type info_type: str + :param data: data + :type data: list of dict + :param serializer: serializer + :param transaction_id: Transaction ID + :type transaction_id: str or int + :param file_path: path + :type file_path: str + :return: path to resulting file + :rtype: str + """ + return (serializer or Serializer()).write_to_path( + (file_path or cls.get_default_info_path(info_type, + transaction_id)), + data) + + @staticmethod + def get_default_info_path(info_type, transaction_id): + """Generate default path for task additional info e.g. deployment info + + :param info_type: deployment_info | cluster_settings | + network_configuration + :type info_type: str + :param transaction_id: Transaction ID + :type transaction_id: str or int + :return: path + :rtype: str + """ + return os.path.join( + os.path.abspath(os.curdir), + "{info_type}_{transaction_id}".format( + info_type=info_type, + transaction_id=transaction_id) + ) + + def download_info_to_file(self, transaction_id, info_type, file_path): + """Get and save to path for task additional info e.g. deployment info + + :param transaction_id: Transaction ID + :type transaction_id: str or int + :param info_type: deployment_info | cluster_settings | + network_configuration + :type info_type: str + :param file_path: path + :type file_path: str + :return: path + :rtype: str + """ + data = self.client.download(transaction_id=transaction_id) + data_file_path = TaskMixIn.write_info_to_file( + info_type, + data, + transaction_id, + file_path) + return data_file_path + + +class TaskInfoFileMixIn(TaskMixIn): + + def get_parser(self, prog_name): + parser = super(TaskInfoFileMixIn, self).get_parser( + prog_name) + parser.add_argument('id', type=int, help='Id of the Task.') + self.add_file_arg(parser) + return parser + + def download_info(self, parsed_args): + data_file_path = self.download_info_to_file( + transaction_id=parsed_args.id, + info_type=self.info_type, + file_path=parsed_args.file) + + return data_file_path + class TaskList(TaskMixIn, base.BaseListCommand): """Show list of all available tasks.""" @@ -85,3 +179,42 @@ class TaskHistoryShow(TaskMixIn, base.BaseListCommand): data = data_utils.get_display_data_multi(self.columns, data) return (self.columns, data) + + +class TaskNetworkConfigurationDownload(TaskInfoFileMixIn, base.BaseCommand): + + entity_name = 'network-configuration' + info_type = 'network_configuration' + + def take_action(self, parsed_args): + self.app.stdout.write( + "Network configuration for task with id={0}" + " downloaded to {1}\n".format(parsed_args.id, + self.download_info(parsed_args)) + ) + + +class TaskDeploymentInfoDownload(TaskInfoFileMixIn, base.BaseCommand): + + entity_name = 'deployment-info' + info_type = 'deployment_info' + + def take_action(self, parsed_args): + self.app.stdout.write( + "Deployment info for task with id={0}" + " downloaded to {1}\n".format(parsed_args.id, + self.download_info(parsed_args)) + ) + + +class TaskClusterSettingsDownload(TaskInfoFileMixIn, base.BaseCommand): + + entity_name = 'cluster-settings' + info_type = 'cluster_settings' + + def take_action(self, parsed_args): + self.app.stdout.write( + "Cluster settings for task with id={0}" + " downloaded to {1}\n".format(parsed_args.id, + self.download_info(parsed_args)) + ) diff --git a/fuelclient/objects/task.py b/fuelclient/objects/task.py index c410c48..10f2226 100644 --- a/fuelclient/objects/task.py +++ b/fuelclient/objects/task.py @@ -23,6 +23,10 @@ class Task(BaseObject): class_api_path = "transactions/" instance_api_path = "transactions/{0}/" + info_types_url_map = { + 'deployment_info': 'deployment_info', + 'cluster_settings': 'settings', + 'network_configuration': 'network_configuration'} def delete(self, force=False): return self.connection.delete_request( @@ -47,6 +51,31 @@ class Task(BaseObject): while not self.is_finished: sleep(0.5) + def deployment_info(self): + return self.connection.get_request( + self._get_additional_info_url('deployment_info')) + + def network_configuration(self): + return self.connection.get_request( + self._get_additional_info_url('network_configuration')) + + def cluster_settings(self): + return self.connection.get_request( + self._get_additional_info_url('cluster_settings')) + + def _get_additional_info_url(self, info_type): + """Generate additional info url. + + :param info_type: one of deployment_info, cluster_settings, + network_configuration + :type info_type: str + :return: url + :rtype: str + """ + + return self.instance_api_path.format(self.id) +\ + self.info_types_url_map[info_type] + class DeployTask(Task): diff --git a/fuelclient/tests/unit/v2/cli/test_task.py b/fuelclient/tests/unit/v2/cli/test_task.py index a1bbd7a..c54d3cd 100644 --- a/fuelclient/tests/unit/v2/cli/test_task.py +++ b/fuelclient/tests/unit/v2/cli/test_task.py @@ -15,6 +15,7 @@ # under the License. import mock +import yaml from fuelclient.tests.unit.v2.cli import test_engine from fuelclient.tests import utils @@ -58,3 +59,41 @@ class TestTaskCommand(test_engine.BaseCLITest): self.m_client.get_all.assert_called_once_with(transaction_id=task_id, nodes=None, statuses=None) + + def _test_cmd(self, cmd, method, cmd_line, client, + return_data, expected_kwargs): + self.m_get_client.reset_mock() + self.m_client.get_filtered.reset_mock() + self.m_client.__getattr__(method).return_value =\ + yaml.safe_load(return_data) + m_open = mock.mock_open() + with mock.patch('fuelclient.cli.serializers.open', + m_open, create=True): + self.exec_command('task {0} {1} {2}'.format(cmd, method, + cmd_line)) + + written_yaml = yaml.safe_load(m_open().write.mock_calls[0][1][0]) + expected_yaml = yaml.safe_load(return_data) + self.assertEqual(written_yaml, expected_yaml) + + self.m_get_client.assert_called_once_with(client, mock.ANY) + self.m_client.__getattr__(method).assert_called_once_with( + **expected_kwargs) + + def test_task_deployment_info_download(self): + self._test_cmd('deployment-info', 'download', '1', + 'deployment-info', + utils.get_fake_yaml_deployment_info(), + dict(transaction_id=1)) + + def test_task_cluster_settings_download(self): + self._test_cmd('settings', 'download', '1', + 'cluster-settings', + utils.get_fake_yaml_cluster_settings(), + dict(transaction_id=1)) + + def test_task_network_configuration_download(self): + self._test_cmd('network-configuration', 'download', '1', + 'network-configuration', + utils.get_fake_yaml_network_conf(), + dict(transaction_id=1)) diff --git a/fuelclient/tests/unit/v2/lib/test_task_additional_info.py b/fuelclient/tests/unit/v2/lib/test_task_additional_info.py new file mode 100644 index 0000000..758e4a8 --- /dev/null +++ b/fuelclient/tests/unit/v2/lib/test_task_additional_info.py @@ -0,0 +1,58 @@ +# -*- 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 yaml + +import fuelclient +from fuelclient.tests.unit.v2.lib import test_api +from fuelclient.tests import utils + + +class TestTaskAdditionalInfoFacade(test_api.BaseLibTest): + + def setUp(self): + super(TestTaskAdditionalInfoFacade, self).setUp() + + self.version = 'v1' + self.task_id = 42 + self.res_uri = ( + '/api/{version}/transactions/{task_id}/'.format( + version=self.version, task_id=self.task_id)) + + def _test_info_download(self, client_name, yaml_data, uri): + client = fuelclient.get_client(client_name, self.version) + expected_body = yaml.load(yaml_data) + matcher = self.m_request.get("{0}{1}".format(self.res_uri, uri), + json=expected_body) + result = client.download(self.task_id) + + self.assertTrue(matcher.called) + self.assertEqual(expected_body, result) + + def test_network_configuration_download(self): + self._test_info_download('network-configuration', + utils.get_fake_yaml_network_conf(), + 'network_configuration') + + def test_cluster_settings_download(self): + self._test_info_download('cluster-settings', + utils.get_fake_yaml_cluster_settings(), + 'settings') + + def test_deployment_info_download(self): + self._test_info_download('deployment-info', + utils.get_fake_yaml_deployment_info(), + 'deployment_info') diff --git a/fuelclient/tests/utils/__init__.py b/fuelclient/tests/utils/__init__.py index 8cb462e..ca79745 100644 --- a/fuelclient/tests/utils/__init__.py +++ b/fuelclient/tests/utils/__init__.py @@ -15,6 +15,12 @@ # under the License. from fuelclient.tests.utils.random_data import random_string +from fuelclient.tests.utils.fake_additional_info \ + import get_fake_yaml_cluster_settings +from fuelclient.tests.utils.fake_additional_info \ + import get_fake_yaml_deployment_info +from fuelclient.tests.utils.fake_additional_info \ + import get_fake_yaml_network_conf from fuelclient.tests.utils.fake_deployment_history \ import get_fake_deployment_history from fuelclient.tests.utils.fake_net_conf import get_fake_interface_config @@ -31,6 +37,9 @@ from fuelclient.tests.utils.fake_openstack_config \ __all__ = (get_fake_deployment_history, + get_fake_yaml_cluster_settings, + get_fake_yaml_deployment_info, + get_fake_yaml_network_conf, get_fake_env, get_fake_fuel_version, get_fake_interface_config, diff --git a/fuelclient/tests/utils/fake_additional_info.py b/fuelclient/tests/utils/fake_additional_info.py new file mode 100644 index 0000000..f2e8d8a --- /dev/null +++ b/fuelclient/tests/utils/fake_additional_info.py @@ -0,0 +1,90 @@ +# -*- 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. + +CLUSTER_SETTINGS = '''--- + editable: + service_user: + name: + type: "hidden" + value: "fuel" + sudo: + type: "hidden" + value: "ALL=(ALL) NOPASSWD: ALL" + homedir: + type: "hidden" + value: "/var/lib/fuel" +''' + +DEPLOYMENT_INFO = '''--- +glance_glare: + user_password: yBw0bY60owLC1C0AplHpEiEX +user_node_name: Untitled (5e:89) +uid: '5' +aodh: + db_password: JnEjYacrjxU2TLdTUQE9LdKq + user_password: 8MhyQgtWjWkl0Dv1r1worTjK +mysql: + root_password: bQhzpWjWIOTHOwEA4qNI8X4K + wsrep_password: 01QSoq3bYHgA7oS0OPYQurgX +murano-cfapi: + db_password: hGrAhxUjv3kAPEjiV7uYNwgZ + user_password: 43x0pvQMXugwd8JBaRSQXX4l + enabled: false + rabbit_password: ZqTnnw7lsGQNOFJRN6pTaI8t +''' + +NETWORK_CONF = '''--- + vips: + vrouter_pub: + network_role: "public/vip" + ipaddr: "10.109.3.2" + namespace: "vrouter" + is_user_defined: false + vendor_specific: + iptables_rules: + ns_start: + - "iptables -t nat -A POSTROUTING -o <%INT%> -j MASQUERADE" +''' + + +def get_fake_yaml_cluster_settings(): + """Create a fake cluster settings + + Returns the serialized and parametrized representation of a dumped Fuel + Cluster Settings. Represents the average amount of data. + + """ + return CLUSTER_SETTINGS + + +def get_fake_yaml_deployment_info(): + """Create a fake cluster settings + + Returns the serialized and parametrized representation of a dumped Fuel + Deployment Info. Represents the average amount of data. + + """ + return DEPLOYMENT_INFO + + +def get_fake_yaml_network_conf(): + """Create a fake cluster settings + + Returns the serialized and parametrized representation of a dumped Fuel + Network Conf. Represents the average amount of data. + + """ + return NETWORK_CONF diff --git a/fuelclient/v1/__init__.py b/fuelclient/v1/__init__.py index 6918556..36de39e 100644 --- a/fuelclient/v1/__init__.py +++ b/fuelclient/v1/__init__.py @@ -12,10 +12,13 @@ # License for the specific language governing permissions and limitations # under the License. +from fuelclient.v1 import cluster_settings from fuelclient.v1 import deployment_history +from fuelclient.v1 import deployment_info from fuelclient.v1 import environment from fuelclient.v1 import fuelversion from fuelclient.v1 import graph +from fuelclient.v1 import network_configuration from fuelclient.v1 import network_group from fuelclient.v1 import node from fuelclient.v1 import openstack_config @@ -24,10 +27,13 @@ from fuelclient.v1 import task from fuelclient.v1 import vip # Please keeps the list in alphabetical order -__all__ = ('deployment_history', +__all__ = ('cluster_settings', + 'deployment_history', + 'deployment_info', 'environment', 'fuelversion', 'graph', + 'network_configuration', 'network_group', 'node', 'openstack_config', diff --git a/fuelclient/v1/cluster_settings.py b/fuelclient/v1/cluster_settings.py new file mode 100644 index 0000000..ffd7093 --- /dev/null +++ b/fuelclient/v1/cluster_settings.py @@ -0,0 +1,31 @@ +# -*- 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 import objects +from fuelclient.v1 import base_v1 + + +class ClusterSettingsClient(base_v1.BaseV1Client): + + _entity_wrapper = objects.Task + + def download(self, transaction_id): + task = self._entity_wrapper(transaction_id) + return task.cluster_settings() + + +def get_client(): + return ClusterSettingsClient() diff --git a/fuelclient/v1/deployment_info.py b/fuelclient/v1/deployment_info.py new file mode 100644 index 0000000..6580dbd --- /dev/null +++ b/fuelclient/v1/deployment_info.py @@ -0,0 +1,31 @@ +# -*- 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 import objects +from fuelclient.v1 import base_v1 + + +class DeploymentInfoClient(base_v1.BaseV1Client): + + _entity_wrapper = objects.Task + + def download(self, transaction_id): + task = self._entity_wrapper(transaction_id) + return task.deployment_info() + + +def get_client(): + return DeploymentInfoClient() diff --git a/fuelclient/v1/network_configuration.py b/fuelclient/v1/network_configuration.py new file mode 100644 index 0000000..337bb71 --- /dev/null +++ b/fuelclient/v1/network_configuration.py @@ -0,0 +1,31 @@ +# -*- 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 import objects +from fuelclient.v1 import base_v1 + + +class NetworkConfigurationClient(base_v1.BaseV1Client): + + _entity_wrapper = objects.Task + + def download(self, transaction_id): + task = self._entity_wrapper(transaction_id) + return task.network_configuration() + + +def get_client(): + return NetworkConfigurationClient() diff --git a/setup.cfg b/setup.cfg index 2265948..9f76328 100644 --- a/setup.cfg +++ b/setup.cfg @@ -66,6 +66,9 @@ fuelclient = task_list=fuelclient.commands.task:TaskList task_show=fuelclient.commands.task:TaskShow task_history_show=fuelclient.commands.task:TaskHistoryShow + task_settings_download=fuelclient.commands.task:TaskClusterSettingsDownload + task_deployment-info_download=fuelclient.commands.task:TaskDeploymentInfoDownload + task_network-configuration_download=fuelclient.commands.task:TaskNetworkConfigurationDownload openstack-config_list=fuelclient.commands.openstack_config:OpenstackConfigList openstack-config_upload=fuelclient.commands.openstack_config:OpenstackConfigUpload openstack-config_download=fuelclient.commands.openstack_config:OpenstackConfigDownload From 76cc06ca3ffe32cc43558be99f28bac280ceadd0 Mon Sep 17 00:00:00 2001 From: Alexander Saprykin Date: Wed, 13 Apr 2016 10:57:39 +0200 Subject: [PATCH 10/11] Fix fuelclient error while trying to create config fuelclient.objects.OpenstackConfig.create now returns list, not a single object. It has to be displayed properly. Change-Id: If8091a34ff6eb90864df9dfd808bb6d5e010bb0a Related-Bug: #1557462 (cherry-picked from commit 027a52f4951f7c0b792abc507cdf8f7064efd421) --- fuelclient/cli/actions/openstack_config.py | 13 +++++++---- fuelclient/commands/openstack_config.py | 22 +++++++++---------- fuelclient/objects/openstack_config.py | 2 +- .../tests/unit/v1/test_openstack_config.py | 7 ++++-- .../unit/v2/cli/test_openstack_config.py | 4 ++-- .../unit/v2/lib/test_openstack_config.py | 14 +++++++----- 6 files changed, 37 insertions(+), 25 deletions(-) diff --git a/fuelclient/cli/actions/openstack_config.py b/fuelclient/cli/actions/openstack_config.py index 7ed1c11..ab85a87 100644 --- a/fuelclient/cli/actions/openstack_config.py +++ b/fuelclient/cli/actions/openstack_config.py @@ -108,13 +108,18 @@ class OpenstackConfigAction(Action): node_role = getattr(params, 'role', None) data = OpenstackConfig.read_file(params.file) - config = OpenstackConfig.create( + configs = OpenstackConfig.create( cluster_id=params.env, configuration=data['configuration'], node_ids=node_ids, node_role=node_role) - print("Openstack configuration with id {0} " - "has been uploaded from file '{1}'" - "".format(config.id, params.file)) + configs = [c.data for c in configs] + self.serializer.print_to_output( + configs, + format_table( + configs, + acceptable_keys=self.acceptable_keys + ) + ) @check_all('config-id') def delete(self, params): diff --git a/fuelclient/commands/openstack_config.py b/fuelclient/commands/openstack_config.py index fc10ae3..6114933 100644 --- a/fuelclient/commands/openstack_config.py +++ b/fuelclient/commands/openstack_config.py @@ -20,6 +20,10 @@ class OpenstackConfigMixin(object): entity_name = 'openstack-config' + columns = ( + 'id', 'is_active', 'config_type', + 'cluster_id', 'node_id', 'node_role') + @staticmethod def add_env_arg(parser): parser.add_argument( @@ -30,7 +34,7 @@ class OpenstackConfigMixin(object): @staticmethod def add_file_arg(parser): parser.add_argument( - '-f', '--file', required=True, + '--file', required=True, type=str, help='YAML file that contains openstack configuration.') @staticmethod @@ -69,14 +73,10 @@ class OpenstackConfigMixin(object): ) -class OpenstackConfigList(OpenstackConfigMixin, base.BaseCommand): +class OpenstackConfigList(OpenstackConfigMixin, base.BaseListCommand): """List all openstack configurations. """ - columns = ( - 'id', 'is_active', 'config_type', - 'cluster_id', 'node_id', 'node_role') - def get_parser(self, prog_name): parser = super(OpenstackConfigList, self).get_parser(prog_name) @@ -116,7 +116,7 @@ class OpenstackConfigDownload(OpenstackConfigMixin, base.BaseCommand): self.app.stdout.write(msg) -class OpenstackConfigUpload(OpenstackConfigMixin, base.BaseCommand): +class OpenstackConfigUpload(OpenstackConfigMixin, base.BaseListCommand): """Upload new opesntack configuration from file. """ @@ -131,13 +131,13 @@ class OpenstackConfigUpload(OpenstackConfigMixin, base.BaseCommand): return parser def take_action(self, args): - config = self.client.upload( + configs = self.client.upload( path=args.file, cluster_id=args.env, node_ids=args.node, node_role=args.role) - msg = "OpenStack configuration with id {0} " \ - "uploaded from file '{0}'\n".format(config.id, args.file) - self.app.stdout.write(msg) + data = [c.data for c in configs] + data = data_utils.get_display_data_multi(self.columns, data) + return self.columns, data class OpenstackConfigExecute(OpenstackConfigMixin, base.BaseCommand): diff --git a/fuelclient/objects/openstack_config.py b/fuelclient/objects/openstack_config.py index c568354..299b040 100644 --- a/fuelclient/objects/openstack_config.py +++ b/fuelclient/objects/openstack_config.py @@ -35,7 +35,7 @@ class OpenstackConfig(BaseObject): def create(cls, **kwargs): params = cls._prepare_params(kwargs) data = cls.connection.post_request(cls.class_api_path, params) - return cls.init_with_data(data) + return [cls.init_with_data(item) for item in data] def delete(self): return self.connection.delete_request( diff --git a/fuelclient/tests/unit/v1/test_openstack_config.py b/fuelclient/tests/unit/v1/test_openstack_config.py index f4c500e..5ad5252 100644 --- a/fuelclient/tests/unit/v1/test_openstack_config.py +++ b/fuelclient/tests/unit/v1/test_openstack_config.py @@ -62,7 +62,7 @@ class TestOpenstackConfigActions(base.UnitTestCase): def test_config_upload(self): m_post = self.m_request.post( - '/api/v1/openstack-config/', json=self.config) + '/api/v1/openstack-config/', json=[self.config]) m_open = mock.mock_open(read_data=yaml.safe_dump( {'configuration': self.config['configuration']})) with mock.patch('fuelclient.cli.serializers.open', @@ -76,8 +76,11 @@ class TestOpenstackConfigActions(base.UnitTestCase): self.assertEqual(req['cluster_id'], 1) def test_config_upload_multinode(self): + configs = [utils.get_fake_openstack_config(node_id=node_id) + for node_id in [1, 2, 3]] + m_post = self.m_request.post( - '/api/v1/openstack-config/', json=self.config) + '/api/v1/openstack-config/', json=configs) m_open = mock.mock_open(read_data=yaml.safe_dump( {'configuration': self.config['configuration']})) diff --git a/fuelclient/tests/unit/v2/cli/test_openstack_config.py b/fuelclient/tests/unit/v2/cli/test_openstack_config.py index 3365840..4988848 100644 --- a/fuelclient/tests/unit/v2/cli/test_openstack_config.py +++ b/fuelclient/tests/unit/v2/cli/test_openstack_config.py @@ -78,7 +78,7 @@ class TestOpenstackConfig(test_engine.BaseCLITest): cmd = 'openstack-config upload --env {0} ' \ '--node {1}'.format(self.CLUSTER_ID, self.NODE_ID) self.assertRaises(SystemExit, self.exec_command, cmd) - self.assertIn('-f/--file', + self.assertIn('--file', mocked_stderr.write.call_args_list[-1][0][0]) mocked_stderr.reset_mock() @@ -101,7 +101,7 @@ class TestOpenstackConfig(test_engine.BaseCLITest): def test_config_download_fail(self, mocked_stderr): cmd = 'openstack-config download 1' self.assertRaises(SystemExit, self.exec_command, cmd) - self.assertIn('-f/--file', + self.assertIn('--file', mocked_stderr.write.call_args_list[-1][0][0]) def test_config_execute(self): diff --git a/fuelclient/tests/unit/v2/lib/test_openstack_config.py b/fuelclient/tests/unit/v2/lib/test_openstack_config.py index 7780ec8..233cd00 100644 --- a/fuelclient/tests/unit/v2/lib/test_openstack_config.py +++ b/fuelclient/tests/unit/v2/lib/test_openstack_config.py @@ -98,7 +98,7 @@ class TestOpenstackConfigClient(test_api.BaseLibTest): fake_config = utils.get_fake_openstack_config( cluster_id=cluster_id) - m_post = self.m_request.post(self.uri, json=fake_config) + m_post = self.m_request.post(self.uri, json=[fake_config]) m_open = mock.mock_open(read_data=yaml.safe_dump({ 'configuration': fake_config['configuration'] @@ -116,13 +116,17 @@ class TestOpenstackConfigClient(test_api.BaseLibTest): def test_config_upload_multinode(self): cluster_id = 1 - fake_config = utils.get_fake_openstack_config( - cluster_id=cluster_id) + fake_configs = [ + utils.get_fake_openstack_config( + cluster_id=cluster_id, node_id=42), + utils.get_fake_openstack_config( + cluster_id=cluster_id, node_id=44) + ] - m_post = self.m_request.post(self.uri, json={'id': 42}) + m_post = self.m_request.post(self.uri, json=fake_configs) m_open = mock.mock_open(read_data=yaml.safe_dump({ - 'configuration': fake_config['configuration'] + 'configuration': fake_configs[0]['configuration'] })) with mock.patch( 'fuelclient.cli.serializers.open', m_open, create=True), \ From fe194d85899a02fcb84f69fc08c98a19186a0417 Mon Sep 17 00:00:00 2001 From: Matthew Mosesohn Date: Fri, 8 Apr 2016 12:34:11 +0300 Subject: [PATCH 11/11] Add notice about updating fuel_client.yaml when updating pass Users are advised to update their configuration after changing their Fuel Admin password. Change-Id: Ie4f7e29092002cb316829ef394e4c938ebf30766 Partial-Bug: #1555262 (cherry picked from commit b384bf90ec774dd8c67c49ba53ec74008f54e1c1) --- fuelclient/cli/actions/user.py | 7 +++++++ fuelclient/fuelclient_settings.py | 12 +++++++----- fuelclient/tests/unit/v1/test_user_action.py | 19 ++++++++++++++++++- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/fuelclient/cli/actions/user.py b/fuelclient/cli/actions/user.py index adf9c6d..62733ca 100644 --- a/fuelclient/cli/actions/user.py +++ b/fuelclient/cli/actions/user.py @@ -18,6 +18,7 @@ from fuelclient.cli.actions.base import Action import fuelclient.cli.arguments as Args from fuelclient.cli.error import ArgumentException from fuelclient.client import APIClient +from fuelclient import fuelclient_settings class UserAction(Action): @@ -56,3 +57,9 @@ class UserAction(Action): password = self._get_password_from_prompt() APIClient.update_own_password(password) + settings = fuelclient_settings.get_settings() + self.serializer.print_to_output( + None, "\nPassword changed.\nPlease note that configuration " + "is not automatically updated.\nYou may want to update " + "{0}.".format( + settings.user_settings)) diff --git a/fuelclient/fuelclient_settings.py b/fuelclient/fuelclient_settings.py index 0eab0e2..9a94652 100644 --- a/fuelclient/fuelclient_settings.py +++ b/fuelclient/fuelclient_settings.py @@ -61,17 +61,19 @@ class FuelClientSettings(object): default_settings = pkg_resources.resource_filename('fuelclient', 'fuel_client.yaml') - user_settings = os.path.join(user_conf_dir, 'fuel', 'fuel_client.yaml') + self.user_settings = os.path.join(user_conf_dir, 'fuel', + 'fuel_client.yaml') custom_settings = os.getenv('FUELCLIENT_CUSTOM_SETTINGS') - if not os.path.exists(user_settings) and not custom_settings: - self.populate_default_settings(default_settings, user_settings) + if not os.path.exists(self.user_settings) and not custom_settings: + self.populate_default_settings(default_settings, + self.user_settings) six.print_('Settings for Fuel Client have been saved to {0}.\n' 'Consider changing default values to the ones which ' - 'are appropriate for you.'.format(user_settings)) + 'are appropriate for you.'.format(self.user_settings)) self._add_file_if_exists(default_settings, settings_files) - self._add_file_if_exists(user_settings, settings_files) + self._add_file_if_exists(self.user_settings, settings_files) # Add a custom settings file specified by user self._add_file_if_exists(custom_settings, settings_files) diff --git a/fuelclient/tests/unit/v1/test_user_action.py b/fuelclient/tests/unit/v1/test_user_action.py index 6b41625..f684875 100644 --- a/fuelclient/tests/unit/v1/test_user_action.py +++ b/fuelclient/tests/unit/v1/test_user_action.py @@ -21,6 +21,9 @@ from fuelclient.tests.unit.v1 import base class TestChangePassword(base.UnitTestCase): + def assert_print(self, print_mock, result, msg): + print_mock.assert_called_once_with(result, msg) + def test_get_password_from_prompt(self): user_action = UserAction() passwd = 'secret!' @@ -38,12 +41,17 @@ class TestChangePassword(base.UnitTestCase): ArgumentException, 'Passwords are not the same'): user_action._get_password_from_prompt() + @mock.patch('fuelclient.cli.serializers.Serializer.print_to_output') + @mock.patch('fuelclient.cli.actions.user.fuelclient_settings') @mock.patch('fuelclient.cli.actions.user.APIClient') - def test_change_password(self, mapiclient): + def test_change_password(self, mapiclient, settings_mock, print_mock): user_action = UserAction() params = mock.Mock() params.newpass = None password = 'secret' + conf_file = '/tmp/fuel_client.yaml' + settings_mock.get_settings.return_value = mock.Mock( + user_settings=conf_file) with mock.patch('fuelclient.cli.actions.user.getpass', return_value=password) as mgetpass: @@ -57,6 +65,15 @@ class TestChangePassword(base.UnitTestCase): mgetpass.assert_has_calls(calls) mapiclient.update_own_password.assert_called_once_with(password) + msg = "\nPassword changed.\nPlease note that configuration " \ + "is not automatically updated.\nYou may want to update " \ + "{0}.".format(conf_file) + + self.assert_print( + print_mock, + None, + msg) + @mock.patch('fuelclient.cli.actions.user.APIClient') def test_change_password_w_newpass(self, mapiclient): user_action = UserAction()