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.
DEPRECATED_VERSIONS = {"1": "2"}
MAX_VERSION = "3.1"
MAX_VERSION = "3.7"
_SUBSTITUTIONS = {}

View File

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

View File

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

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

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
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='<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>',
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

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.