diff --git a/fuelclient/cli/actions/__init__.py b/fuelclient/cli/actions/__init__.py index 8538262..edc3bb5 100644 --- a/fuelclient/cli/actions/__init__.py +++ b/fuelclient/cli/actions/__init__.py @@ -42,34 +42,36 @@ from fuelclient.cli.actions.task import TaskAction from fuelclient.cli.actions.user import UserAction from fuelclient.cli.actions.plugins import PluginAction from fuelclient.cli.actions.fuelversion import FuelVersionAction +from fuelclient.cli.actions.vip import VIPAction actions_tuple = ( - ReleaseAction, - RoleAction, - EnvironmentAction, DeployChangesAction, - NodeAction, DeploymentAction, - ProvisioningAction, - StopAction, - ResetAction, - SettingsAction, - VmwareSettingsAction, - NetworkAction, - NetworkTemplateAction, - TaskAction, - SnapshotAction, + EnvironmentAction, + FuelVersionAction, + GraphAction, HealthCheckAction, - UserAction, - PluginAction, + NetworkAction, + NetworkGroupAction, + NetworkTemplateAction, + NodeAction, NodeGroupAction, NotificationsAction, NotifyAction, - TokenAction, - GraphAction, - FuelVersionAction, - NetworkGroupAction, OpenstackConfigAction, + PluginAction, + ProvisioningAction, + ReleaseAction, + ResetAction, + RoleAction, + SettingsAction, + SnapshotAction, + StopAction, + TaskAction, + TokenAction, + UserAction, + VIPAction, + VmwareSettingsAction, ) actions = dict( diff --git a/fuelclient/cli/actions/vip.py b/fuelclient/cli/actions/vip.py new file mode 100644 index 0000000..c01bfa7 --- /dev/null +++ b/fuelclient/cli/actions/vip.py @@ -0,0 +1,76 @@ +# 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.actions.base import Action +import fuelclient.cli.arguments as Args +from fuelclient.objects.environment import Environment + + +class VIPAction(Action): + """Download or upload VIP settings of specific environments. + """ + action_name = "vip" + acceptable_keys = ("id", "upload", "download", "network", "network-role",) + + def __init__(self): + super(VIPAction, self).__init__() + self.args = ( + Args.get_env_arg(required=True), + Args.get_upload_file_arg("Upload changed VIP configuration " + "from given file"), + Args.get_download_arg("Download VIP configuration"), + Args.get_file_arg("Target file with vip data."), + Args.get_ip_id_arg("IP address entity identifier"), + Args.get_network_id_arg("Network identifier"), + Args.get_network_role_arg("Network role string"), + ) + self.flag_func_map = ( + ("upload", self.upload), + ("download", self.download) + ) + + def upload(self, params): + """To upload VIP configuration from some + file for some environment: + fuel --env 1 vip --upload vip.yaml + """ + env = Environment(params.env) + vips_data = env.read_vips_data_from_file( + file_path=params.upload, + serializer=self.serializer + ) + env.set_vips_data(vips_data) + print("VIP configuration uploaded.") + + def download(self, params): + """To download VIP configuration in this + file for some environment: + fuel --env 1 vip --download --file vip.yaml + where --file param is optional + """ + env = Environment(params.env) + vips_data = env.get_vips_data( + ip_address_id=getattr(params, 'ip-address-id'), + network=getattr(params, 'network'), + network_role=getattr(params, 'network-role') + ) + vips_data_file_path = env.write_vips_data_to_file( + vips_data, + file_path=params.file, + serializer=self.serializer + ) + print( + "VIP configuration for environment with id={0}" + " downloaded to {1}".format(env.id, vips_data_file_path) + ) diff --git a/fuelclient/cli/arguments.py b/fuelclient/cli/arguments.py index 6801941..5fda65e 100644 --- a/fuelclient/cli/arguments.py +++ b/fuelclient/cli/arguments.py @@ -631,3 +631,43 @@ def get_notify_topic_arg(help_msg): ), help=help_msg ) + + +def get_vip_arg(help_msg): + return get_boolean_arg( + "vip", + flags=("--vip",), + help=help_msg + ) + + +def get_ip_id_arg(help_msg): + return get_int_arg( + "ip-address-id", + flags=("--ip-address-id",), + help=help_msg + ) + + +def get_network_id_arg(help_msg): + return get_int_arg( + "network", + flags=("--network",), + help=help_msg + ) + + +def get_network_role_arg(help_msg): + return get_str_arg( + "network-role", + flags=("--network-role",), + help=help_msg + ) + + +def get_upload_file_arg(help_msg): + return get_str_arg( + "upload", + flags=("-u", "--upload",), + help=help_msg + ) diff --git a/fuelclient/objects/environment.py b/fuelclient/objects/environment.py index 0b6f18b..407c60d 100644 --- a/fuelclient/objects/environment.py +++ b/fuelclient/objects/environment.py @@ -16,9 +16,7 @@ from operator import attrgetter import os import shutil -from fuelclient.cli.error import ActionException -from fuelclient.cli.error import InvalidDirectoryException -from fuelclient.cli.error import ServerDataException +from fuelclient.cli import error from fuelclient.cli.serializers import listdir_without_extensions from fuelclient.objects.base import BaseObject from fuelclient.objects.task import DeployTask @@ -94,7 +92,7 @@ class Environment(BaseObject): def unassign_all(self): nodes = self.get_all_nodes() if not nodes: - raise ActionException( + raise error.ActionException( "Environment with id={0} doesn't have nodes to remove." .format(self.id) ) @@ -181,12 +179,17 @@ class Environment(BaseObject): return (serializer or self.serializer).read_from_file( settings_file_path) + def _check_file_path(self, file_path): + if not os.path.exists(file_path): + raise error.InvalidFileException( + "File '{0}' doesn't exist.".format(file_path)) + def _check_dir(self, directory): if not os.path.exists(directory): - raise InvalidDirectoryException( + raise error.InvalidDirectoryException( "Directory '{0}' doesn't exist.".format(directory)) if not os.path.isdir(directory): - raise InvalidDirectoryException( + raise error.InvalidDirectoryException( "Error: '{0}' is not a directory.".format(directory)) def read_vmware_settings_data(self, directory=os.curdir, serializer=None): @@ -311,7 +314,7 @@ class Environment(BaseObject): facts = self.connection.get_request( self._get_fact_default_url(fact_type, nodes=nodes)) if not facts: - raise ServerDataException( + raise error.ServerDataException( "There is no {0} info for this " "environment!".format(fact_type) ) @@ -321,7 +324,7 @@ class Environment(BaseObject): facts = self.connection.get_request( self._get_fact_url(fact_type, nodes=nodes)) if not facts: - raise ServerDataException( + raise error.ServerDataException( "There is no {0} info for this " "environment!".format(fact_type) ) @@ -513,3 +516,112 @@ class Environment(BaseObject): def spawn_vms(self): url = 'clusters/{0}/spawn_vms/'.format(self.id) return self.connection.put_request(url, {}) + + def _get_ip_addrs_url(self, vips=True, ip_addr_id=None): + """Generate ip address management url. + + :param vips: manage vip properties of ip address + :type vips: bool + :param ip_addr_id: ip address identifier + :type ip_addr_id: int + :return: url + :rtype: str + """ + ip_addr_url = "clusters/{0}/network_configuration/ips/".format(self.id) + if ip_addr_id: + ip_addr_url += '{0}/'.format(ip_addr_id) + if vips: + ip_addr_url += 'vips/' + + return ip_addr_url + + def get_default_vips_data_path(self): + """Get path where VIPs data is located. + :return: path + :rtype: str + """ + return os.path.join( + os.path.abspath(os.curdir), + "vips_{0}".format(self.id) + ) + + def get_vips_data(self, ip_address_id=None, network=None, + network_role=None): + """Get one or multiple vip data records. + + :param ip_address_id: ip addr id could be specified to download single + vip if no ip_addr_id specified multiple entities is + returned respecting network and network_role + filters + :type ip_address_id: int + :param network: network id could be specified to filter vips + :type network: int + :param network_role: network role could be specified to filter vips + :type network_role: string + :return: response JSON + :rtype: list of dict + """ + params = {} + if network: + params['network'] = network + if network_role: + params['network-role'] = network_role + + result = self.connection.get_request( + self._get_ip_addrs_url(True, ip_addr_id=ip_address_id), + params=params + ) + if ip_address_id is not None: # single vip is returned + # wrapping with list is required to respect case when administrator + # is downloading vip address info to change it and upload + # back. Uploading works only with lists of records. + result = [result] + return result + + def write_vips_data_to_file(self, vips_data, serializer=None, + file_path=None): + """Write VIP data to the given path. + + :param vips_data: vip data + :type vips_data: list of dict + :param serializer: serializer + :param file_path: path + :type file_path: str + :return: path to resulting file + :rtype: str + """ + serializer = serializer or self.serializer + + if file_path: + return serializer.write_to_full_path( + file_path, + vips_data + ) + else: + return serializer.write_to_path( + self.get_default_vips_data_path(), + vips_data + ) + + def read_vips_data_from_file(self, file_path=None, serializer=None): + """Read VIPs data from given path. + + :param file_path: path + :type file_path: str + :param serializer: serializer object + :type serializer: object + :return: data + :rtype: list|object + """ + self._check_file_path(file_path) + return (serializer or self.serializer).read_from_file(file_path) + + def set_vips_data(self, data): + """Sending VIPs data to the Nailgun API. + + :param data: VIPs data + :type data: list of dict + :return: request result + :rtype: object + """ + return self.connection.put_request(self._get_ip_addrs_url(), data) diff --git a/fuelclient/tests/functional/v1/test_client.py b/fuelclient/tests/functional/v1/test_client.py index 2ceaae2..ca50bcb 100644 --- a/fuelclient/tests/functional/v1/test_client.py +++ b/fuelclient/tests/functional/v1/test_client.py @@ -181,7 +181,7 @@ class TestHandlers(base.BaseTestCase): actions = ( "node", "stop", "deployment", "reset", "task", "network", "settings", "provisioning", "environment", "deploy-changes", - "role", "release", "snapshot", "health" + "role", "release", "snapshot", "health", "vip" ) for action in actions: self.check_all_in_msg("{0} -h".format(action), ("Examples",)) diff --git a/fuelclient/tests/unit/v1/test_vip_action.py b/fuelclient/tests/unit/v1/test_vip_action.py new file mode 100644 index 0000000..6b8e7c4 --- /dev/null +++ b/fuelclient/tests/unit/v1/test_vip_action.py @@ -0,0 +1,186 @@ +# -*- 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 six +import yaml + +from fuelclient.tests.unit.v1 import base + +ENV_OUTPUT = { + "status": "new", + "is_customized": False, + "release_id": 2, + "ui_settings": { + "sort": [{"roles": "asc"}], + "sort_by_labels": [], + "search": "", + "filter_by_labels": {}, + "filter": {}, + "view_mode": "standard" + }, + "is_locked": False, + "fuel_version": "8.0", + "net_provider": "neutron", + "mode": "ha_compact", + "components": [], + "pending_release_id": None, + "changes": [ + {"node_id": None, "name": "attributes"}, + {"node_id": None, "name": "networks"}, + {"node_id": None, "name": "vmware_attributes"}], + "id": 6, "name": "test_not_deployed"} + + +MANY_VIPS_YAML = '''- id: 5 + network: 3 + node: null + ip_addr: 192.169.1.33 + vip_name: public + vip_namespace: haproxy + is_user_defined: false +- id: 6 + network: 3 + node: null + ip_addr: 192.169.1.34 + vip_namespace: null + vip_name: private + is_user_defined: true +''' + +ONE_VIP_YAML = ''' +- id: 5 + network: 3 + node: null + ip_addr: 192.169.1.33 + vip_name: public + vip_namespace: haproxy + is_user_defined: false +''' + + +@mock.patch('fuelclient.cli.serializers.open', create=True) +@mock.patch('fuelclient.cli.actions.base.os') +class TestVIPActions(base.UnitTestCase): + + def test_env_vips_download(self, mos, mopen): + self.m_request.get('/api/v1/clusters/1/', json=ENV_OUTPUT) + url = '/api/v1/clusters/1/network_configuration/ips/vips/' + get_request = self.m_request.get( + url, + json=yaml.load(MANY_VIPS_YAML)) + self.execute('fuel vip --env 1 --download'.split()) + self.assertTrue(get_request.called) + self.assertEqual(1, mopen().__enter__().write.call_count) + self.assertEqual( + yaml.safe_load(MANY_VIPS_YAML), + yaml.safe_load(mopen().__enter__().write.call_args[0][0]), + ) + + def test_env_vips_download_with_network_id(self, mos, mopen): + self.m_request.get('/api/v1/clusters/1/', json=ENV_OUTPUT) + url = '/api/v1/clusters/1/network_configuration/ips/vips/' + get_request = self.m_request.get( + url, + json=yaml.load(MANY_VIPS_YAML)) + self.execute('fuel vip --env 1 --network 3 --download'.split()) + self.assertTrue(get_request.called) + self.assertEqual(1, mopen().__enter__().write.call_count) + self.assertEqual( + yaml.safe_load(MANY_VIPS_YAML), + yaml.safe_load(mopen().__enter__().write.call_args[0][0]), + ) + + def test_env_vips_download_with_network_role(self, mos, mopen): + self.m_request.get('/api/v1/clusters/1/', json=ENV_OUTPUT) + url = '/api/v1/clusters/1/network_configuration/ips/vips/' + get_request = self.m_request.get( + url, + json=yaml.load(MANY_VIPS_YAML)) + self.execute( + 'fuel vip --env 1 --network-role some/role --download'.split()) + self.assertTrue(get_request.called) + self.assertEqual(1, mopen().__enter__().write.call_count) + self.assertEqual( + yaml.safe_load(MANY_VIPS_YAML), + yaml.safe_load(mopen().__enter__().write.call_args[0][0]), + ) + + def test_env_single_vip_download(self, mos, mopen): + self.m_request.get('/api/v1/clusters/1/', json=ENV_OUTPUT) + url = '/api/v1/clusters/1/network_configuration/ips/5/vips/' + get_request = self.m_request.get( + url, + json=yaml.safe_load(ONE_VIP_YAML)[0] + ) + self.execute('fuel vip --env 1 --ip-address-id 5 --download'.split()) + + self.assertTrue(get_request.called) + self.assertEqual(1, mopen().__enter__().write.call_count) + + self.assertEqual( + yaml.safe_load(ONE_VIP_YAML), + yaml.safe_load(mopen().__enter__().write.call_args[0][0]), + ) + self.assertIn( + 'vips_1.yaml', + mopen.call_args_list[0][0][0] + ) + + def test_env_single_vip_download_to_file(self, mos, mopen): + self.m_request.get('/api/v1/clusters/1/', json=ENV_OUTPUT) + url = '/api/v1/clusters/1/network_configuration/ips/5/vips/' + get_request = self.m_request.get( + url, + json=yaml.safe_load(ONE_VIP_YAML)[0] + ) + self.execute('fuel vip --env 1 --ip-address-id 5 ' + '--download --file vips.yaml'.split()) + + self.assertTrue(get_request.called) + self.assertEqual(1, mopen().__enter__().write.call_count) + + self.assertEqual( + yaml.safe_load(ONE_VIP_YAML), + yaml.safe_load(mopen().__enter__().write.call_args[0][0]), + ) + self.assertEqual( + 'vips.yaml', + mopen.call_args_list[0][0][0] + ) + + def test_vips_upload(self, mos, mopen): + url = '/api/v1/clusters/1/network_configuration/ips/vips/' + mopen().__enter__().read.return_value = MANY_VIPS_YAML + self.m_request.get('/api/v1/clusters/1/', json=ENV_OUTPUT) + request_put = self.m_request.put(url, json={}) + with mock.patch('fuelclient.objects.environment.os') as env_os: + env_os.path.exists.return_value = True + self.execute('fuel vip --env 1 --upload vips_1.yaml'.split()) + self.assertEqual(env_os.path.exists.call_count, 1) + self.assertEqual(request_put.call_count, 1) + self.assertIn(url, request_put.last_request.url) + + def test_vips_upload_bad_path(self, mos, mopen): + with mock.patch('sys.stderr', new=six.moves.cStringIO()) as mstderr: + with mock.patch('fuelclient.objects.environment.os') as env_os: + env_os.path.exists.return_value = False + self.assertRaises( + SystemExit, + self.execute, + 'fuel vip --env 1 --upload vips_1.yaml'.split() + ) + self.assertIn("doesn't exist", mstderr.getvalue())