Add cluster related commands

This patch updates client to support cluster related changes on the API
done on microversion 3.7.

Service listing will include "cluster_name" field and we have 4 new
commands, "cluster-list", "cluster-show", "cluster-enable" and
"cluster-disable".

Specs: https://review.openstack.org/327283
Implements: blueprint cinder-volume-active-active-support
Depends-On: If1ef3a80900ca6d117bf854ad3de142d93694adf
Change-Id: I824f46b876e21e552d9f0c5cd3e836f35ea31837
This commit is contained in:
Gorka Eguileor 2016-06-09 14:11:41 +02:00
parent c95539753d
commit 25bc7e7402
9 changed files with 474 additions and 2 deletions

View File

@ -32,7 +32,7 @@ if not LOG.handlers:
# key is a deprecated version and value is an alternative version. # key is a deprecated version and value is an alternative version.
DEPRECATED_VERSIONS = {"1": "2"} DEPRECATED_VERSIONS = {"1": "2"}
MAX_VERSION = "3.1" MAX_VERSION = "3.7"
_SUBSTITUTIONS = {} _SUBSTITUTIONS = {}

View File

@ -27,7 +27,10 @@ class ServicesTest(utils.TestCase):
svs = cs.services.list() svs = cs.services.list()
cs.assert_called('GET', '/os-services') cs.assert_called('GET', '/os-services')
self.assertEqual(3, len(svs)) 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) self._assert_request_id(svs)
def test_list_services_with_hostname(self): def test_list_services_with_hostname(self):

View File

@ -12,6 +12,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from datetime import datetime
from cinderclient.tests.unit import fakes from cinderclient.tests.unit import fakes
from cinderclient.v3 import client from cinderclient.v3 import client
from cinderclient.tests.unit.v2 import fakes as fake_v2 from cinderclient.tests.unit.v2 import fakes as fake_v2
@ -34,3 +36,138 @@ class FakeHTTPClient(fake_v2.FakeHTTPClient):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super(FakeHTTPClient, self).__init__() super(FakeHTTPClient, self).__init__()
self.management_url = 'http://10.0.2.15:8776/v3/fake' 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]})

View File

@ -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)

View File

@ -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)

View File

@ -17,6 +17,7 @@ from cinderclient import client
from cinderclient import api_versions from cinderclient import api_versions
from cinderclient.v3 import availability_zones from cinderclient.v3 import availability_zones
from cinderclient.v3 import cgsnapshots from cinderclient.v3 import cgsnapshots
from cinderclient.v3 import clusters
from cinderclient.v3 import consistencygroups from cinderclient.v3 import consistencygroups
from cinderclient.v3 import capabilities from cinderclient.v3 import capabilities
from cinderclient.v3 import limits from cinderclient.v3 import limits
@ -77,6 +78,7 @@ class Client(object):
self.restores = volume_backups_restore.VolumeBackupRestoreManager(self) self.restores = volume_backups_restore.VolumeBackupRestoreManager(self)
self.transfers = volume_transfers.VolumeTransferManager(self) self.transfers = volume_transfers.VolumeTransferManager(self)
self.services = services.ServiceManager(self) self.services = services.ServiceManager(self)
self.clusters = clusters.ClusterManager(self)
self.consistencygroups = consistencygroups.\ self.consistencygroups = consistencygroups.\
ConsistencygroupManager(self) ConsistencygroupManager(self)
self.cgsnapshots = cgsnapshots.CgsnapshotManager(self) self.cgsnapshots = cgsnapshots.CgsnapshotManager(self)

View File

@ -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 "<Cluster: %s (id: %s)>" % (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)

View File

@ -24,6 +24,7 @@ import time
import six import six
from cinderclient import api_versions
from cinderclient import base from cinderclient import base
from cinderclient import exceptions from cinderclient import exceptions
from cinderclient import utils from cinderclient import utils
@ -1604,6 +1605,80 @@ def do_transfer_create(cs, args):
utils.print_dict(info) utils.print_dict(info)
@utils.service_type('volumev3')
@api_versions.wraps('3.7')
@utils.arg('--name', metavar='<name>', default=None,
help='Filter by cluster name, without backend will list all '
'clustered services from the same cluster. Default=None.')
@utils.arg('--binary', metavar='<binary>', default=None,
help='Cluster binary. Default=None.')
@utils.arg('--is-up', metavar='<True|true|False|false>', default=None,
choices=('True', 'true', 'False', 'false'),
help='Filter by up/dow status. Default=None.')
@utils.arg('--disabled', metavar='<True|true|False|false>', default=None,
choices=('True', 'true', 'False', 'false'),
help='Filter by disabled status. Default=None.')
@utils.arg('--num-hosts', metavar='<num-hosts>', default=None,
help='Filter by number of hosts in the cluster.')
@utils.arg('--num-down-hosts', metavar='<num-down-hosts>', 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='<binary>', nargs='?', default='cinder-volume',
help='Binary to filter by. Default: cinder-volume.')
@utils.arg('name', metavar='<cluster-name>',
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='<binary>', nargs='?', default='cinder-volume',
help='Binary to filter by. Default: cinder-volume.')
@utils.arg('name', metavar='<cluster-name>',
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='<binary>', nargs='?', default='cinder-volume',
help='Binary to filter by. Default: cinder-volume.')
@utils.arg('name', metavar='<cluster-name>',
help='Name of the clustered services to update.')
@utils.arg('--reason', metavar='<reason>', 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='<transfer>', @utils.arg('transfer', metavar='<transfer>',
help='Name or ID of transfer to delete.') help='Name or ID of transfer to delete.')
@utils.service_type('volumev3') @utils.service_type('volumev3')
@ -1696,6 +1771,8 @@ def do_service_list(cs, args):
replication = strutils.bool_from_string(args.withreplication) replication = strutils.bool_from_string(args.withreplication)
result = cs.services.list(host=args.host, binary=args.binary) result = cs.services.list(host=args.host, binary=args.binary)
columns = ["Binary", "Host", "Zone", "Status", "State", "Updated_at"] columns = ["Binary", "Host", "Zone", "Status", "State", "Updated_at"]
if cs.api_version.matches('3.7'):
columns.append('Cluster')
if replication: if replication:
columns.extend(["Replication Status", "Active Backend ID", "Frozen"]) columns.extend(["Replication Status", "Active Backend ID", "Frozen"])
# NOTE(jay-lau-513): we check if the response has disabled_reason # NOTE(jay-lau-513): we check if the response has disabled_reason

View File

@ -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.