diff --git a/cinderclient/api_versions.py b/cinderclient/api_versions.py index a74f3af24..90f0ac6f6 100644 --- a/cinderclient/api_versions.py +++ b/cinderclient/api_versions.py @@ -32,7 +32,7 @@ if not LOG.handlers: # key is a deprecated version and value is an alternative version. DEPRECATED_VERSIONS = {"1": "2"} -MAX_VERSION = "3.1" +MAX_VERSION = "3.7" _SUBSTITUTIONS = {} diff --git a/cinderclient/tests/unit/v2/test_services.py b/cinderclient/tests/unit/v2/test_services.py index 4e08e69fe..d355d7fb1 100644 --- a/cinderclient/tests/unit/v2/test_services.py +++ b/cinderclient/tests/unit/v2/test_services.py @@ -27,7 +27,10 @@ class ServicesTest(utils.TestCase): svs = cs.services.list() cs.assert_called('GET', '/os-services') self.assertEqual(3, len(svs)) - [self.assertIsInstance(s, services.Service) for s in svs] + for service in svs: + self.assertIsInstance(service, services.Service) + # Make sure cluster fields from v3.7 are not there + self.assertFalse(hasattr(service, 'cluster')) self._assert_request_id(svs) def test_list_services_with_hostname(self): diff --git a/cinderclient/tests/unit/v3/fakes.py b/cinderclient/tests/unit/v3/fakes.py index 679012a2a..430f82ff1 100644 --- a/cinderclient/tests/unit/v3/fakes.py +++ b/cinderclient/tests/unit/v3/fakes.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import datetime + from cinderclient.tests.unit import fakes from cinderclient.v3 import client from cinderclient.tests.unit.v2 import fakes as fake_v2 @@ -34,3 +36,138 @@ class FakeHTTPClient(fake_v2.FakeHTTPClient): def __init__(self, **kwargs): super(FakeHTTPClient, self).__init__() self.management_url = 'http://10.0.2.15:8776/v3/fake' + vars(self).update(kwargs) + + # + # Services + # + def get_os_services(self, **kw): + host = kw.get('host', None) + binary = kw.get('binary', None) + services = [ + { + 'id': 1, + 'binary': 'cinder-volume', + 'host': 'host1', + 'zone': 'cinder', + 'status': 'enabled', + 'state': 'up', + 'updated_at': datetime(2012, 10, 29, 13, 42, 2), + 'cluster': 'cluster1', + }, + { + 'id': 2, + 'binary': 'cinder-volume', + 'host': 'host2', + 'zone': 'cinder', + 'status': 'disabled', + 'state': 'down', + 'updated_at': datetime(2012, 9, 18, 8, 3, 38), + 'cluster': 'cluster1', + }, + { + 'id': 3, + 'binary': 'cinder-scheduler', + 'host': 'host2', + 'zone': 'cinder', + 'status': 'disabled', + 'state': 'down', + 'updated_at': datetime(2012, 9, 18, 8, 3, 38), + 'cluster': 'cluster2', + }, + ] + if host: + services = list(filter(lambda i: i['host'] == host, services)) + if binary: + services = list(filter(lambda i: i['binary'] == binary, services)) + if not self.api_version.matches('3.7'): + for svc in services: + del svc['cluster'] + return (200, {}, {'services': services}) + + # + # Clusters + # + def _filter_clusters(self, return_keys, **kw): + date = datetime(2012, 10, 29, 13, 42, 2), + clusters = [ + { + 'id': '1', + 'name': 'cluster1@lvmdriver-1', + 'state': 'up', + 'status': 'enabled', + 'binary': 'cinder-volume', + 'is_up': 'True', + 'disabled': 'False', + 'disabled_reason': None, + 'num_hosts': '3', + 'num_down_hosts': '2', + 'updated_at': date, + 'created_at': date, + 'last_heartbeat': date, + }, + { + 'id': '2', + 'name': 'cluster1@lvmdriver-2', + 'state': 'down', + 'status': 'enabled', + 'binary': 'cinder-volume', + 'is_up': 'False', + 'disabled': 'False', + 'disabled_reason': None, + 'num_hosts': '2', + 'num_down_hosts': '2', + 'updated_at': date, + 'created_at': date, + 'last_heartbeat': date, + }, + { + 'id': '3', + 'name': 'cluster2', + 'state': 'up', + 'status': 'disabled', + 'binary': 'cinder-backup', + 'is_up': 'True', + 'disabled': 'True', + 'disabled_reason': 'Reason', + 'num_hosts': '1', + 'num_down_hosts': '0', + 'updated_at': date, + 'created_at': date, + 'last_heartbeat': date, + }, + ] + + for key, value in kw.items(): + clusters = [cluster for cluster in clusters + if cluster[key] == str(value)] + + result = [] + for cluster in clusters: + result.append({key: cluster[key] for key in return_keys}) + return result + + CLUSTER_SUMMARY_KEYS = ('name', 'binary', 'state', 'status') + CLUSTER_DETAIL_KEYS = (CLUSTER_SUMMARY_KEYS + + ('num_hosts', 'num_down_hosts', 'last_heartbeat', + 'disabled_reason', 'created_at', 'updated_at')) + + def get_clusters(self, **kw): + clusters = self._filter_clusters(self.CLUSTER_SUMMARY_KEYS, **kw) + return (200, {}, {'clusters': clusters}) + + def get_clusters_detail(self, **kw): + clusters = self._filter_clusters(self.CLUSTER_DETAIL_KEYS, **kw) + return (200, {}, {'clusters': clusters}) + + def get_clusters_1(self): + res = self.get_clusters_detail(id=1) + return (200, {}, {'cluster': res[2]['clusters'][0]}) + + def put_clusters_enable(self, body): + res = self.get_clusters(id=1) + return (200, {}, {'cluster': res[2]['clusters'][0]}) + + def put_clusters_disable(self, body): + res = self.get_clusters(id=3) + return (200, {}, {'cluster': res[2]['clusters'][0]}) diff --git a/cinderclient/tests/unit/v3/test_clusters.py b/cinderclient/tests/unit/v3/test_clusters.py new file mode 100644 index 000000000..9b788e7d0 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_clusters.py @@ -0,0 +1,128 @@ +# Copyright (c) 2016 Red Hat Inc. +# All Rights Reserved. +# +# 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 cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes +import ddt + + +cs = fakes.FakeClient() + + +@ddt.ddt +class ClusterTest(utils.TestCase): + def _check_fields_present(self, clusters, detailed=False): + expected_keys = {'name', 'binary', 'state', 'status'} + + if detailed: + expected_keys.update(('num_hosts', 'num_down_hosts', + 'last_heartbeat', 'disabled_reason', + 'created_at', 'updated_at')) + + for cluster in clusters: + self.assertEqual(expected_keys, set(cluster.to_dict())) + + def _assert_call(self, base_url, detailed, params=None, method='GET', + body=None): + url = base_url + if detailed: + url += '/detail' + if params: + url += '?' + params + if body: + cs.assert_called(method, url, body) + else: + cs.assert_called(method, url) + + @ddt.data(True, False) + def test_clusters_list(self, detailed): + lst = cs.clusters.list(detailed=detailed) + self._assert_call('/clusters', detailed) + self.assertEqual(3, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + @ddt.data(True, False) + def test_cluster_list_name(self, detailed): + lst = cs.clusters.list(name='cluster1@lvmdriver-1', + detailed=detailed) + self._assert_call('/clusters', detailed, + 'name=cluster1@lvmdriver-1') + self.assertEqual(1, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + @ddt.data(True, False) + def test_clusters_list_binary(self, detailed): + lst = cs.clusters.list(binary='cinder-volume', detailed=detailed) + self._assert_call('/clusters', detailed, 'binary=cinder-volume') + self.assertEqual(2, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + @ddt.data(True, False) + def test_clusters_list_is_up(self, detailed): + lst = cs.clusters.list(is_up=True, detailed=detailed) + self._assert_call('/clusters', detailed, 'is_up=True') + self.assertEqual(2, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + @ddt.data(True, False) + def test_clusters_list_disabled(self, detailed): + lst = cs.clusters.list(disabled=True, detailed=detailed) + self._assert_call('/clusters', detailed, 'disabled=True') + self.assertEqual(1, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + @ddt.data(True, False) + def test_clusters_list_num_hosts(self, detailed): + lst = cs.clusters.list(num_hosts=1, detailed=detailed) + self._assert_call('/clusters', detailed, 'num_hosts=1') + self.assertEqual(1, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + @ddt.data(True, False) + def test_clusters_list_num_down_hosts(self, detailed): + lst = cs.clusters.list(num_down_hosts=2, detailed=detailed) + self._assert_call('/clusters', detailed, 'num_down_hosts=2') + self.assertEqual(2, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + def test_cluster_show(self): + result = cs.clusters.show('1') + self._assert_call('/clusters/1', False) + self._assert_request_id(result) + self._check_fields_present([result], True) + + def test_cluster_enable(self): + body = {'binary': 'cinder-volume', 'name': 'cluster@lvmdriver-1'} + result = cs.clusters.update(body['name'], body['binary'], False, + disabled_reason='is ignored') + self._assert_call('/clusters/enable', False, method='PUT', body=body) + self._assert_request_id(result) + self._check_fields_present([result], False) + + def test_cluster_disable(self): + body = {'binary': 'cinder-volume', 'name': 'cluster@lvmdriver-1', + 'disabled_reason': 'is passed'} + result = cs.clusters.update(body['name'], body['binary'], True, + body['disabled_reason']) + self._assert_call('/clusters/disable', False, method='PUT', body=body) + self._assert_request_id(result) + self._check_fields_present([result], False) diff --git a/cinderclient/tests/unit/v3/test_services.py b/cinderclient/tests/unit/v3/test_services.py new file mode 100644 index 000000000..a77a9964d --- /dev/null +++ b/cinderclient/tests/unit/v3/test_services.py @@ -0,0 +1,33 @@ +# Copyright (c) 2016 Red Hat Inc. +# All Rights Reserved. +# +# 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 cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3 import services +from cinderclient import api_versions + + +class ServicesTest(utils.TestCase): + + def test_list_services_with_cluster_info(self): + cs = fakes.FakeClient(api_version=api_versions.APIVersion('3.7')) + services_list = cs.services.list() + cs.assert_called('GET', '/os-services') + self.assertEqual(3, len(services_list)) + for service in services_list: + self.assertIsInstance(service, services.Service) + # Make sure cluster fields from v3.7 is present and not None + self.assertIsNotNone(getattr(service, 'cluster')) + self._assert_request_id(services_list) diff --git a/cinderclient/v3/client.py b/cinderclient/v3/client.py index ae10ebc0f..a6191e09b 100644 --- a/cinderclient/v3/client.py +++ b/cinderclient/v3/client.py @@ -17,6 +17,7 @@ from cinderclient import client from cinderclient import api_versions from cinderclient.v3 import availability_zones from cinderclient.v3 import cgsnapshots +from cinderclient.v3 import clusters from cinderclient.v3 import consistencygroups from cinderclient.v3 import capabilities from cinderclient.v3 import limits @@ -77,6 +78,7 @@ class Client(object): self.restores = volume_backups_restore.VolumeBackupRestoreManager(self) self.transfers = volume_transfers.VolumeTransferManager(self) self.services = services.ServiceManager(self) + self.clusters = clusters.ClusterManager(self) self.consistencygroups = consistencygroups.\ ConsistencygroupManager(self) self.cgsnapshots = cgsnapshots.CgsnapshotManager(self) diff --git a/cinderclient/v3/clusters.py b/cinderclient/v3/clusters.py new file mode 100644 index 000000000..96f749712 --- /dev/null +++ b/cinderclient/v3/clusters.py @@ -0,0 +1,83 @@ +# Copyright (c) 2016 Red Hat, Inc. +# All Rights Reserved. +# +# 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. + +""" +Interface to clusters API +""" +from cinderclient import base + + +class Cluster(base.Resource): + def __repr__(self): + return "" % (self.name, self.id) + + +class ClusterManager(base.ManagerWithFind): + resource_class = Cluster + base_url = '/clusters' + + def _build_url(self, url_path=None, **kwargs): + url = self.base_url + ('/' + url_path if url_path else '') + filters = {'%s=%s' % (k, v) for k, v in kwargs.items() if v} + if filters: + url = "%s?%s" % (url, "&".join(filters)) + return url + + def list(self, name=None, binary=None, is_up=None, disabled=None, + num_hosts=None, num_down_hosts=None, detailed=False): + """Clustered Service list. + + :param name: filter by cluster name. + :param binary: filter by cluster binary. + :param is_up: filtering by up/down status. + :param disabled: filtering by disabled status. + :param num_hosts: filtering by number of hosts. + :param num_down_hosts: filtering by number of hosts that are down. + :param detailed: retrieve simple or detailed list. + """ + url_path = 'detail' if detailed else None + url = self._build_url(url_path, name=name, binary=binary, is_up=is_up, + disabled=disabled, num_hosts=num_hosts, + num_down_hosts=num_down_hosts) + return self._list(url, 'clusters') + + def show(self, name, binary=None): + """Clustered Service show. + + :param name: Cluster name. + :param binary: Clustered service binary. + """ + url = self._build_url(name, binary=binary) + resp, body = self.api.client.get(url) + return self.resource_class(self, body['cluster'], loaded=True, + resp=resp) + + def update(self, name, binary, disabled, disabled_reason=None): + """Enable or disable a clustered service. + + :param name: Cluster name. + :param binary: Clustered service binary. + :param disabled: Boolean determining desired disabled status. + :param disabled_reason: Value to pass as disabled reason. + """ + url_path = 'disable' if disabled else 'enable' + url = self._build_url(url_path) + + body = {'name': name, 'binary': binary} + if disabled and disabled_reason: + body['disabled_reason'] = disabled_reason + result = self._update(url, body) + return self.resource_class(self, result['cluster'], loaded=True, + resp=result.request_ids) diff --git a/cinderclient/v3/shell.py b/cinderclient/v3/shell.py index 235f9ddbd..7798fea64 100644 --- a/cinderclient/v3/shell.py +++ b/cinderclient/v3/shell.py @@ -24,6 +24,7 @@ import time import six +from cinderclient import api_versions from cinderclient import base from cinderclient import exceptions from cinderclient import utils @@ -1604,6 +1605,80 @@ def do_transfer_create(cs, args): utils.print_dict(info) +@utils.service_type('volumev3') +@api_versions.wraps('3.7') +@utils.arg('--name', metavar='', default=None, + help='Filter by cluster name, without backend will list all ' + 'clustered services from the same cluster. Default=None.') +@utils.arg('--binary', metavar='', default=None, + help='Cluster binary. Default=None.') +@utils.arg('--is-up', metavar='', default=None, + choices=('True', 'true', 'False', 'false'), + help='Filter by up/dow status. Default=None.') +@utils.arg('--disabled', metavar='', default=None, + choices=('True', 'true', 'False', 'false'), + help='Filter by disabled status. Default=None.') +@utils.arg('--num-hosts', metavar='', default=None, + help='Filter by number of hosts in the cluster.') +@utils.arg('--num-down-hosts', metavar='', default=None, + help='Filter by number of hosts that are down.') +@utils.arg('--detailed', dest='detailed', default=False, + help='Get detailed clustered service information (Default=False).', + action='store_true') +def do_cluster_list(cs, args): + """Lists clustered services with optional filtering.""" + clusters = cs.clusters.list(name=args.name, binary=args.binary, + is_up=args.is_up, disabled=args.disabled, + num_hosts=args.num_hosts, + num_down_hosts=args.num_down_hosts, + detailed=args.detailed) + + columns = ['Name', 'Binary', 'State', 'Status'] + if args.detailed: + columns.extend(('Num Hosts', 'Num Down Hosts', 'Last Heartbeat', + 'Disabled Reason', 'Created At', 'Updated at')) + utils.print_list(clusters, columns) + + +@utils.service_type('volumev3') +@api_versions.wraps('3.7') +@utils.arg('binary', metavar='', nargs='?', default='cinder-volume', + help='Binary to filter by. Default: cinder-volume.') +@utils.arg('name', metavar='', + help='Name of the clustered service to show.') +def do_cluster_show(cs, args): + """Show detailed information on a clustered service.""" + cluster = cs.clusters.show(args.name, args.binary) + utils.print_dict(cluster.to_dict()) + + +@utils.service_type('volumev3') +@api_versions.wraps('3.7') +@utils.arg('binary', metavar='', nargs='?', default='cinder-volume', + help='Binary to filter by. Default: cinder-volume.') +@utils.arg('name', metavar='', + help='Name of the clustered services to update.') +def do_cluster_enable(cs, args): + """Enables clustered services.""" + cluster = cs.clusters.update(args.name, args.binary, disabled=False) + utils.print_dict(cluster.to_dict()) + + +@utils.service_type('volumev3') +@api_versions.wraps('3.7') +@utils.arg('binary', metavar='', nargs='?', default='cinder-volume', + help='Binary to filter by. Default: cinder-volume.') +@utils.arg('name', metavar='', + help='Name of the clustered services to update.') +@utils.arg('--reason', metavar='', default=None, + help='Reason for disabling clustered service.') +def do_cluster_disable(cs, args): + """Disables clustered services.""" + cluster = cs.clusters.update(args.name, args.binary, disabled=True, + disabled_reason=args.reason) + utils.print_dict(cluster.to_dict()) + + @utils.arg('transfer', metavar='', help='Name or ID of transfer to delete.') @utils.service_type('volumev3') @@ -1696,6 +1771,8 @@ def do_service_list(cs, args): replication = strutils.bool_from_string(args.withreplication) result = cs.services.list(host=args.host, binary=args.binary) columns = ["Binary", "Host", "Zone", "Status", "State", "Updated_at"] + if cs.api_version.matches('3.7'): + columns.append('Cluster') if replication: columns.extend(["Replication Status", "Active Backend ID", "Frozen"]) # NOTE(jay-lau-513): we check if the response has disabled_reason diff --git a/releasenotes/notes/cluster_commands-dca50e89c9d53cd2.yaml b/releasenotes/notes/cluster_commands-dca50e89c9d53cd2.yaml new file mode 100644 index 000000000..ccebb1e14 --- /dev/null +++ b/releasenotes/notes/cluster_commands-dca50e89c9d53cd2.yaml @@ -0,0 +1,9 @@ +--- +features: + - Service listings will display additional "cluster" field when working with + microversion 3.7 or higher. + - Add clustered services commands to list -summary and detailed- + (`cluster-list`), show (`cluster-show`), and update (`cluster-enable`, + `cluster-disable`). Listing supports filtering by name, binary, + disabled status, number of hosts, number of hosts that are down, and + up/down status. These commands require API version 3.7 or higher.