587 lines
22 KiB
Python
587 lines
22 KiB
Python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
|
|
|
# Copyright (c) 2013 Huawei Technologies Co., Ltd.
|
|
# Copyright (c) 2012 OpenStack Foundation
|
|
# 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.
|
|
"""
|
|
Volume Drivers for Huawei OceanStor T series storage arrays.
|
|
"""
|
|
|
|
import re
|
|
import time
|
|
|
|
from cinder import exception
|
|
from cinder.openstack.common import log as logging
|
|
from cinder.volume import driver
|
|
from cinder.volume.drivers.huawei import huawei_utils
|
|
from cinder.volume.drivers.huawei import ssh_common
|
|
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
HOST_PORT_PREFIX = 'HostPort_'
|
|
|
|
|
|
class HuaweiTISCSIDriver(driver.ISCSIDriver):
|
|
"""ISCSI driver for Huawei OceanStor T series storage arrays."""
|
|
|
|
VERSION = '1.1.0'
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(HuaweiTISCSIDriver, self).__init__(*args, **kwargs)
|
|
|
|
def do_setup(self, context):
|
|
"""Instantiate common class."""
|
|
self.common = ssh_common.TseriesCommon(configuration=
|
|
self.configuration)
|
|
self.common.do_setup(context)
|
|
self._assert_cli_out = self.common._assert_cli_out
|
|
self._assert_cli_operate_out = self.common._assert_cli_operate_out
|
|
|
|
def check_for_setup_error(self):
|
|
"""Check something while starting."""
|
|
self.common.check_for_setup_error()
|
|
|
|
def create_volume(self, volume):
|
|
"""Create a new volume."""
|
|
volume_id = self.common.create_volume(volume)
|
|
return {'provider_location': volume_id}
|
|
|
|
def create_volume_from_snapshot(self, volume, snapshot):
|
|
"""Create a volume from a snapshot."""
|
|
volume_id = self.common.create_volume_from_snapshot(volume, snapshot)
|
|
return {'provider_location': volume_id}
|
|
|
|
def create_cloned_volume(self, volume, src_vref):
|
|
"""Create a clone of the specified volume."""
|
|
volume_id = self.common.create_cloned_volume(volume, src_vref)
|
|
return {'provider_location': volume_id}
|
|
|
|
def delete_volume(self, volume):
|
|
"""Delete a volume."""
|
|
self.common.delete_volume(volume)
|
|
|
|
def create_export(self, context, volume):
|
|
"""Export the volume."""
|
|
pass
|
|
|
|
def ensure_export(self, context, volume):
|
|
"""Synchronously recreate an export for a volume."""
|
|
pass
|
|
|
|
def remove_export(self, context, volume):
|
|
"""Remove an export for a volume."""
|
|
pass
|
|
|
|
def create_snapshot(self, snapshot):
|
|
"""Create a snapshot."""
|
|
snapshot_id = self.common.create_snapshot(snapshot)
|
|
return {'provider_location': snapshot_id}
|
|
|
|
def delete_snapshot(self, snapshot):
|
|
"""Delete a snapshot."""
|
|
self.common.delete_snapshot(snapshot)
|
|
|
|
def initialize_connection(self, volume, connector):
|
|
"""Map a volume to a host and return target iSCSI information."""
|
|
LOG.debug(_('initialize_connection: volume name: %(vol)s, '
|
|
'host: %(host)s, initiator: %(ini)s')
|
|
% {'vol': volume['name'],
|
|
'host': connector['host'],
|
|
'ini': connector['initiator']})
|
|
|
|
self.common._update_login_info()
|
|
(iscsi_iqn, target_ip, port_ctr) =\
|
|
self._get_iscsi_params(connector['initiator'])
|
|
|
|
# First, add a host if not added before.
|
|
host_id = self.common.add_host(connector['host'], connector['ip'],
|
|
connector['initiator'])
|
|
|
|
# Then, add the iSCSI port to the host.
|
|
self._add_iscsi_port_to_host(host_id, connector)
|
|
|
|
# Finally, map the volume to the host.
|
|
volume_id = volume['provider_location']
|
|
hostlun_id = self.common.map_volume(host_id, volume_id)
|
|
|
|
# Change LUN ctr for better performance, just for single path.
|
|
lun_details = self.common.get_lun_details(volume_id)
|
|
if (lun_details['LunType'] == 'THICK' and
|
|
lun_details['OwningController'] != port_ctr):
|
|
self.common.change_lun_ctr(volume_id, port_ctr)
|
|
|
|
properties = {}
|
|
properties['target_discovered'] = False
|
|
properties['target_portal'] = ('%s:%s' % (target_ip, '3260'))
|
|
properties['target_iqn'] = iscsi_iqn
|
|
properties['target_lun'] = int(hostlun_id)
|
|
properties['volume_id'] = volume['id']
|
|
auth = volume['provider_auth']
|
|
if auth:
|
|
(auth_method, auth_username, auth_secret) = auth.split()
|
|
|
|
properties['auth_method'] = auth_method
|
|
properties['auth_username'] = auth_username
|
|
properties['auth_password'] = auth_secret
|
|
|
|
return {'driver_volume_type': 'iscsi', 'data': properties}
|
|
|
|
def _get_iscsi_params(self, initiator):
|
|
"""Get target iSCSI params, including iqn and IP."""
|
|
conf_file = self.common.configuration.cinder_huawei_conf_file
|
|
iscsi_conf = self._get_iscsi_conf(conf_file)
|
|
target_ip = None
|
|
for ini in iscsi_conf['Initiator']:
|
|
if ini['Name'] == initiator:
|
|
target_ip = ini['TargetIP']
|
|
break
|
|
# If didn't specify target IP for some initiator, use default IP.
|
|
if not target_ip:
|
|
if iscsi_conf['DefaultTargetIP']:
|
|
target_ip = iscsi_conf['DefaultTargetIP']
|
|
|
|
else:
|
|
msg = (_('_get_iscsi_params: Failed to get target IP '
|
|
'for initiator %(ini)s, please check config file.')
|
|
% {'ini': initiator})
|
|
LOG.error(msg)
|
|
raise exception.InvalidInput(reason=msg)
|
|
|
|
(target_iqn, port_ctr) = self._get_tgt_iqn(target_ip)
|
|
return (target_iqn, target_ip, port_ctr)
|
|
|
|
def _get_iscsi_conf(self, filename):
|
|
"""Get iSCSI info from config file.
|
|
|
|
This function returns a dict:
|
|
{'DefaultTargetIP': '11.11.11.11',
|
|
'Initiator': [{'Name': 'iqn.xxxxxx.1', 'TargetIP': '11.11.11.12'},
|
|
{'Name': 'iqn.xxxxxx.2', 'TargetIP': '11.11.11.13'}
|
|
]
|
|
}
|
|
|
|
"""
|
|
|
|
iscsiinfo = {}
|
|
root = huawei_utils.parse_xml_file(filename)
|
|
|
|
default_ip = root.findtext('iSCSI/DefaultTargetIP')
|
|
if default_ip:
|
|
iscsiinfo['DefaultTargetIP'] = default_ip.strip()
|
|
else:
|
|
iscsiinfo['DefaultTargetIP'] = None
|
|
initiator_list = []
|
|
tmp_dic = {}
|
|
for dic in root.findall('iSCSI/Initiator'):
|
|
# Strip the values of dict.
|
|
for k, v in dic.items():
|
|
tmp_dic[k] = v.strip()
|
|
initiator_list.append(tmp_dic)
|
|
iscsiinfo['Initiator'] = initiator_list
|
|
return iscsiinfo
|
|
|
|
def _get_tgt_iqn(self, port_ip):
|
|
"""Run CLI command to get target iSCSI iqn.
|
|
|
|
The iqn is formed with three parts:
|
|
iSCSI target name + iSCSI port info + iSCSI IP
|
|
|
|
"""
|
|
|
|
LOG.debug(_('_get_tgt_iqn: iSCSI IP is %s.') % port_ip)
|
|
|
|
cli_cmd = 'showiscsitgtname'
|
|
out = self.common._execute_cli(cli_cmd)
|
|
|
|
self._assert_cli_out(re.search('ISCSI Name', out),
|
|
'_get_tgt_iqn',
|
|
'Failed to get iSCSI target %s iqn.' % port_ip,
|
|
cli_cmd, out)
|
|
|
|
lines = out.split('\r\n')
|
|
index = lines[4].index('iqn')
|
|
iqn_prefix = lines[4][index:].strip()
|
|
# Here we make sure port_info won't be None.
|
|
port_info = self._get_iscsi_tgt_port_info(port_ip)
|
|
ctr = ('0' if port_info[0] == 'A' else '1')
|
|
interface = '0' + port_info[1]
|
|
port = '0' + port_info[2][1:]
|
|
iqn_suffix = ctr + '02' + interface + port
|
|
# iqn_suffix should not start with 0
|
|
while(True):
|
|
if iqn_suffix.startswith('0'):
|
|
iqn_suffix = iqn_suffix[1:]
|
|
else:
|
|
break
|
|
|
|
iqn = iqn_prefix + ':' + iqn_suffix + ':' + port_info[3]
|
|
|
|
LOG.debug(_('_get_tgt_iqn: iSCSI target iqn is %s.') % iqn)
|
|
|
|
return (iqn, port_info[0])
|
|
|
|
def _get_iscsi_tgt_port_info(self, port_ip):
|
|
"""Get iSCSI Port information of storage device."""
|
|
cli_cmd = 'showiscsiip'
|
|
out = self.common._execute_cli(cli_cmd)
|
|
if re.search('iSCSI IP Information', out):
|
|
for line in out.split('\r\n')[6:-2]:
|
|
tmp_line = line.split()
|
|
if tmp_line[3] == port_ip:
|
|
return tmp_line
|
|
|
|
err_msg = _('_get_iscsi_tgt_port_info: Failed to get iSCSI port '
|
|
'info. Please make sure the iSCSI port IP %s is '
|
|
'configured in array.') % port_ip
|
|
LOG.error(err_msg)
|
|
raise exception.VolumeBackendAPIException(data=err_msg)
|
|
|
|
def _add_iscsi_port_to_host(self, hostid, connector, multipathtype=0):
|
|
"""Add an iSCSI port to the given host.
|
|
|
|
First, add an initiator if needed, the initiator is equivalent to
|
|
an iSCSI port. Then, add the initiator to host if not added before.
|
|
|
|
"""
|
|
|
|
initiator = connector['initiator']
|
|
# Add an iSCSI initiator.
|
|
if not self._initiator_added(initiator):
|
|
self._add_initiator(initiator)
|
|
# Add the initiator to host if not added before.
|
|
port_name = HOST_PORT_PREFIX + str(hash(initiator))
|
|
portadded = False
|
|
hostport_info = self.common._get_host_port_info(hostid)
|
|
if hostport_info:
|
|
for hostport in hostport_info:
|
|
if hostport[2] == initiator:
|
|
portadded = True
|
|
break
|
|
if not portadded:
|
|
cli_cmd = ('addhostport -host %(id)s -type 5 '
|
|
'-info %(info)s -n %(name)s -mtype %(multype)s'
|
|
% {'id': hostid,
|
|
'info': initiator,
|
|
'name': port_name,
|
|
'multype': multipathtype})
|
|
out = self.common._execute_cli(cli_cmd)
|
|
|
|
msg = ('Failed to add iSCSI port %(port)s to host %(host)s'
|
|
% {'port': port_name,
|
|
'host': hostid})
|
|
self._assert_cli_operate_out('_add_iscsi_port_to_host',
|
|
msg, cli_cmd, out)
|
|
|
|
def _initiator_added(self, ininame):
|
|
"""Check whether the initiator is already added."""
|
|
cli_cmd = 'showiscsiini -ini %(name)s' % {'name': ininame}
|
|
out = self.common._execute_cli(cli_cmd)
|
|
return (True if re.search('Initiator Information', out) else False)
|
|
|
|
def _add_initiator(self, ininame):
|
|
"""Add a new initiator to storage device."""
|
|
cli_cmd = 'addiscsiini -n %(name)s' % {'name': ininame}
|
|
out = self.common._execute_cli(cli_cmd)
|
|
|
|
self._assert_cli_operate_out('_add_iscsi_host_port',
|
|
'Failed to add initiator %s' % ininame,
|
|
cli_cmd, out)
|
|
|
|
def _delete_initiator(self, ininame, attempts=2):
|
|
"""Delete an initiator."""
|
|
cli_cmd = 'deliscsiini -n %(name)s' % {'name': ininame}
|
|
while(attempts > 0):
|
|
out = self.common._execute_cli(cli_cmd)
|
|
if re.search('the port is in use', out):
|
|
attempts -= 1
|
|
time.sleep(2)
|
|
else:
|
|
break
|
|
|
|
self._assert_cli_operate_out('_map_lun',
|
|
'Failed to delete initiator %s.'
|
|
% ininame,
|
|
cli_cmd, out)
|
|
|
|
def terminate_connection(self, volume, connector, **kwargs):
|
|
"""Terminate the map."""
|
|
LOG.debug(_('terminate_connection: volume: %(vol)s, host: %(host)s, '
|
|
'connector: %(initiator)s')
|
|
% {'vol': volume['name'],
|
|
'host': connector['host'],
|
|
'initiator': connector['initiator']})
|
|
|
|
self.common._update_login_info()
|
|
host_id = self.common.remove_map(volume['provider_location'],
|
|
connector['host'],
|
|
connector['initiator'])
|
|
if not self.common._get_host_map_info(host_id):
|
|
self._remove_iscsi_port(host_id, connector)
|
|
|
|
def _remove_iscsi_port(self, hostid, connector):
|
|
"""Remove iSCSI ports and delete host."""
|
|
initiator = connector['initiator']
|
|
# Delete the host initiator if no LUN mapped to it.
|
|
port_num = 0
|
|
port_info = self.common._get_host_port_info(hostid)
|
|
if port_info:
|
|
port_num = len(port_info)
|
|
for port in port_info:
|
|
if port[2] == initiator:
|
|
self.common._delete_hostport(port[0])
|
|
self._delete_initiator(initiator)
|
|
port_num -= 1
|
|
break
|
|
else:
|
|
LOG.warn(_('_remove_iscsi_port: iSCSI port was not found '
|
|
'on host %(hostid)s.') % {'hostid': hostid})
|
|
|
|
# Delete host if no initiator added to it.
|
|
if port_num == 0:
|
|
self.common._delete_host(hostid)
|
|
|
|
def get_volume_stats(self, refresh=False):
|
|
"""Get volume stats."""
|
|
self._stats = self.common.get_volume_stats(refresh)
|
|
self._stats['storage_protocol'] = 'iSCSI'
|
|
self._stats['driver_version'] = self.VERSION
|
|
backend_name = self.configuration.safe_get('volume_backend_name')
|
|
self._stats['volume_backend_name'] = (backend_name or
|
|
self.__class__.__name__)
|
|
return self._stats
|
|
|
|
|
|
class HuaweiTFCDriver(driver.FibreChannelDriver):
|
|
"""FC driver for Huawei OceanStor T series storage arrays."""
|
|
|
|
VERSION = '1.0.0'
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super(HuaweiTFCDriver, self).__init__(*args, **kwargs)
|
|
|
|
def do_setup(self, context):
|
|
"""Instantiate common class."""
|
|
self.common = ssh_common.TseriesCommon(configuration=
|
|
self.configuration)
|
|
self.common.do_setup(context)
|
|
self._assert_cli_out = self.common._assert_cli_out
|
|
self._assert_cli_operate_out = self.common._assert_cli_operate_out
|
|
|
|
def check_for_setup_error(self):
|
|
"""Check something while starting."""
|
|
self.common.check_for_setup_error()
|
|
|
|
def create_volume(self, volume):
|
|
"""Create a new volume."""
|
|
volume_id = self.common.create_volume(volume)
|
|
return {'provider_location': volume_id}
|
|
|
|
def create_volume_from_snapshot(self, volume, snapshot):
|
|
"""Create a volume from a snapshot."""
|
|
volume_id = self.common.create_volume_from_snapshot(volume, snapshot)
|
|
return {'provider_location': volume_id}
|
|
|
|
def create_cloned_volume(self, volume, src_vref):
|
|
"""Create a clone of the specified volume."""
|
|
volume_id = self.common.create_cloned_volume(volume, src_vref)
|
|
return {'provider_location': volume_id}
|
|
|
|
def delete_volume(self, volume):
|
|
"""Delete a volume."""
|
|
self.common.delete_volume(volume)
|
|
|
|
def create_export(self, context, volume):
|
|
"""Export the volume."""
|
|
pass
|
|
|
|
def ensure_export(self, context, volume):
|
|
"""Synchronously recreate an export for a volume."""
|
|
pass
|
|
|
|
def remove_export(self, context, volume):
|
|
"""Remove an export for a volume."""
|
|
pass
|
|
|
|
def create_snapshot(self, snapshot):
|
|
"""Create a snapshot."""
|
|
snapshot_id = self.common.create_snapshot(snapshot)
|
|
return {'provider_location': snapshot_id}
|
|
|
|
def delete_snapshot(self, snapshot):
|
|
"""Delete a snapshot."""
|
|
self.common.delete_snapshot(snapshot)
|
|
|
|
def validate_connector(self, connector):
|
|
"""Check for wwpns in connector."""
|
|
if 'wwpns' not in connector:
|
|
err_msg = (_('validate_connector: The FC driver requires the'
|
|
'wwpns in the connector.'))
|
|
LOG.error(err_msg)
|
|
raise exception.VolumeBackendAPIException(data=err_msg)
|
|
|
|
def initialize_connection(self, volume, connector):
|
|
"""Create FC connection between a volume and a host."""
|
|
LOG.debug(_('initialize_connection: volume name: %(vol)s, '
|
|
'host: %(host)s, initiator: %(wwn)s')
|
|
% {'vol': volume['name'],
|
|
'host': connector['host'],
|
|
'wwn': connector['wwpns']})
|
|
|
|
self.common._update_login_info()
|
|
# First, add a host if it is not added before.
|
|
host_id = self.common.add_host(connector['host'], connector['ip'])
|
|
# Then, add free FC ports to the host.
|
|
ini_wwns = connector['wwpns']
|
|
free_wwns = self._get_connected_free_wwns()
|
|
for wwn in free_wwns:
|
|
if wwn in ini_wwns:
|
|
self._add_fc_port_to_host(host_id, wwn)
|
|
fc_port_details = self._get_host_port_details(host_id)
|
|
tgt_wwns = self._get_tgt_fc_port_wwns(fc_port_details)
|
|
|
|
LOG.debug(_('initialize_connection: Target FC ports WWNS: %s')
|
|
% tgt_wwns)
|
|
|
|
# Finally, map the volume to the host.
|
|
volume_id = volume['provider_location']
|
|
hostlun_id = self.common.map_volume(host_id, volume_id)
|
|
|
|
# Change LUN ctr for better performance, just for single path.
|
|
if len(tgt_wwns) == 1:
|
|
lun_details = self.common.get_lun_details(volume_id)
|
|
port_ctr = self._get_fc_port_ctr(fc_port_details[0])
|
|
if (lun_details['LunType'] == 'THICK' and
|
|
lun_details['OwningController'] != port_ctr):
|
|
self.common.change_lun_ctr(volume_id, port_ctr)
|
|
|
|
properties = {}
|
|
properties['target_discovered'] = False
|
|
properties['target_wwn'] = tgt_wwns
|
|
properties['target_lun'] = int(hostlun_id)
|
|
properties['volume_id'] = volume['id']
|
|
|
|
return {'driver_volume_type': 'fibre_channel',
|
|
'data': properties}
|
|
|
|
def _get_connected_free_wwns(self):
|
|
"""Get free connected FC port WWNs.
|
|
|
|
If no new ports connected, return an empty list.
|
|
|
|
"""
|
|
|
|
cli_cmd = 'showfreeport'
|
|
out = self.common._execute_cli(cli_cmd)
|
|
wwns = []
|
|
if re.search('Host Free Port Information', out):
|
|
for line in out.split('\r\n')[6:-2]:
|
|
tmp_line = line.split()
|
|
if (tmp_line[1] == 'FC') and (tmp_line[4] == 'Connected'):
|
|
wwns.append(tmp_line[0])
|
|
|
|
return wwns
|
|
|
|
def _add_fc_port_to_host(self, hostid, wwn, multipathtype=0):
|
|
"""Add a FC port to host."""
|
|
portname = HOST_PORT_PREFIX + wwn
|
|
cli_cmd = ('addhostport -host %(id)s -type 1 '
|
|
'-wwn %(wwn)s -n %(name)s -mtype %(multype)s'
|
|
% {'id': hostid,
|
|
'wwn': wwn,
|
|
'name': portname,
|
|
'multype': multipathtype})
|
|
out = self.common._execute_cli(cli_cmd)
|
|
|
|
msg = ('Failed to add FC port %(port)s to host %(host)s.'
|
|
% {'port': portname, 'host': hostid})
|
|
self._assert_cli_operate_out('_add_fc_port_to_host', msg, cli_cmd, out)
|
|
|
|
def _get_host_port_details(self, host_id):
|
|
cli_cmd = 'showhostpath -host %s' % host_id
|
|
out = self.common._execute_cli(cli_cmd)
|
|
|
|
self._assert_cli_out(re.search('Multi Path Information', out),
|
|
'_get_host_port_details',
|
|
'Failed to get host port details.',
|
|
cli_cmd, out)
|
|
|
|
port_details = []
|
|
tmp_details = {}
|
|
for line in out.split('\r\n')[4:-2]:
|
|
line = line.split('|')
|
|
# Cut-point of multipal details, usually is "-------".
|
|
if len(line) == 1:
|
|
port_details.append(tmp_details)
|
|
continue
|
|
key = ''.join(line[0].strip().split())
|
|
val = line[1].strip()
|
|
tmp_details[key] = val
|
|
port_details.append(tmp_details)
|
|
return port_details
|
|
|
|
def _get_tgt_fc_port_wwns(self, port_details):
|
|
wwns = []
|
|
for port in port_details:
|
|
wwns.append(port['TargetWWN'])
|
|
return wwns
|
|
|
|
def _get_fc_port_ctr(self, port_details):
|
|
return port_details['ControllerID']
|
|
|
|
def terminate_connection(self, volume, connector, **kwargs):
|
|
"""Terminate the map."""
|
|
LOG.debug(_('terminate_connection: volume: %(vol)s, host: %(host)s, '
|
|
'connector: %(initiator)s')
|
|
% {'vol': volume['name'],
|
|
'host': connector['host'],
|
|
'initiator': connector['initiator']})
|
|
|
|
self.common._update_login_info()
|
|
host_id = self.common.remove_map(volume['provider_location'],
|
|
connector['host'])
|
|
# Remove all FC ports and delete the host if
|
|
# no volume mapping to it.
|
|
if not self.common._get_host_map_info(host_id):
|
|
self._remove_fc_ports(host_id, connector)
|
|
|
|
def _remove_fc_ports(self, hostid, connector):
|
|
"""Remove FC ports and delete host."""
|
|
wwns = connector['wwpns']
|
|
port_num = 0
|
|
port_info = self.common._get_host_port_info(hostid)
|
|
if port_info:
|
|
port_num = len(port_info)
|
|
for port in port_info:
|
|
if port[2] in wwns:
|
|
self.common._delete_hostport(port[0])
|
|
port_num -= 1
|
|
else:
|
|
LOG.warn(_('_remove_fc_ports: FC port was not found '
|
|
'on host %(hostid)s.') % {'hostid': hostid})
|
|
|
|
if port_num == 0:
|
|
self.common._delete_host(hostid)
|
|
|
|
def get_volume_stats(self, refresh=False):
|
|
"""Get volume stats."""
|
|
self._stats = self.common.get_volume_stats(refresh)
|
|
self._stats['storage_protocol'] = 'FC'
|
|
self._stats['driver_version'] = self.VERSION
|
|
backend_name = self.configuration.safe_get('volume_backend_name')
|
|
self._stats['volume_backend_name'] = (backend_name or
|
|
self.__class__.__name__)
|
|
return self._stats
|