57
64
CONF.register_opts(coraid_opts)
60
class CoraidException(Exception):
61
def __init__(self, message=None, error=None):
62
super(CoraidException, self).__init__(message, error)
65
return '%s: %s' % self.args
68
class CoraidRESTException(CoraidException):
72
class CoraidESMException(CoraidException):
67
ESM_SESSION_EXPIRED_STATES = ['GeneralAdminFailure',
68
'passwordInactivityTimeout',
69
'passwordAbsoluteTimeout']
76
72
class CoraidRESTClient(object):
77
"""Executes volume driver commands on Coraid ESM EtherCloud Appliance."""
79
def __init__(self, ipaddress, user, group, password):
80
self.url = "https://%s:8443/" % ipaddress
83
self.password = password
85
self.cookiejar = cookielib.CookieJar()
86
self.urlOpener = urllib2.build_opener(
87
urllib2.HTTPCookieProcessor(self.cookiejar))
88
LOG.debug(_('Running with CoraidDriver for ESM EtherCLoud'))
73
"""Executes REST RPC requests on Coraid ESM EtherCloud Appliance."""
75
def __init__(self, esm_url):
76
self._check_esm_url(esm_url)
77
self._esm_url = esm_url
78
self._cookie_jar = cookielib.CookieJar()
79
self._url_opener = urllib2.build_opener(
80
urllib2.HTTPCookieProcessor(self._cookie_jar))
82
def _check_esm_url(self, esm_url):
83
splitted = urlparse.urlsplit(esm_url)
84
if splitted.scheme != 'https':
86
_('Invalid ESM url scheme "%s". Supported https only.') %
89
@lockutils.synchronized('coraid_rpc', 'cinder-', False)
90
def rpc(self, handle, url_params, data, allow_empty_response=False):
91
return self._rpc(handle, url_params, data, allow_empty_response)
93
def _rpc(self, handle, url_params, data, allow_empty_response):
94
"""Execute REST RPC using url <esm_url>/handle?url_params.
96
Send JSON encoded data in body of POST request.
100
1. Name or service not found (e.reason is socket.gaierror)
101
2. Socket blocking operation timeout (e.reason is
103
3. Network IO error (e.reason is socket.error)
106
1. HTTP 404, HTTP 500 etc.
108
CoraidJsonEncodeFailure - bad REST response
110
# Handle must be simple path, for example:
112
if '?' in handle or '&' in handle:
113
raise ValueError(_('Invalid REST handle name. Expected path.'))
115
# Request url includes base ESM url, handle path and optional
117
rest_url = urlparse.urljoin(self._esm_url, handle)
118
encoded_url_params = urllib.urlencode(url_params)
119
if encoded_url_params:
120
rest_url += '?' + encoded_url_params
125
json_request = jsonutils.dumps(data)
127
request = urllib2.Request(rest_url, json_request)
128
response = self._url_opener.open(request).read()
131
if not response and allow_empty_response:
134
reply = jsonutils.loads(response)
135
except (TypeError, ValueError) as exc:
136
msg = (_('Call to json.loads() failed: %(ex)s.'
137
' Response: %(resp)s') %
138
{'ex': exc, 'resp': response})
139
raise exception.CoraidJsonEncodeFailure(msg)
144
def to_coraid_kb(gb):
145
return math.ceil(float(gb) * units.GiB / 1000)
148
def coraid_volume_size(gb):
149
return '{0}K'.format(to_coraid_kb(gb))
152
class CoraidAppliance(object):
153
def __init__(self, rest_client, username, password, group):
154
self._rest_client = rest_client
155
self._username = username
156
self._password = password
158
self._logined = False
91
"""Login and Session Handler."""
92
if not self.session or self.session < time.time():
93
url = ('admin?op=login&username=%s&password=%s' %
94
(self.user, self.password))
96
reply = self._admin_esm_cmd(url, data)
97
if reply.get('state') == 'adminSucceed':
98
self.session = time.time() + 1100
99
msg = _('Update session cookie %(session)s')
100
LOG.debug(msg % dict(session=self.session))
101
self._set_group(reply)
104
errmsg = reply.get('message', '')
105
msg = _('Message : %(message)s')
106
raise CoraidESMException(msg % dict(message=errmsg))
109
def _set_group(self, reply):
110
"""Set effective group."""
113
groupId = self._get_group_id(group, reply)
115
url = ('admin?op=setRbacGroup&groupId=%s' % (groupId))
117
reply = self._admin_esm_cmd(url, data)
118
if reply.get('state') == 'adminSucceed':
121
errmsg = reply.get('message', '')
122
msg = _('Error while trying to set group: %(message)s')
123
raise CoraidRESTException(msg % dict(message=errmsg))
125
msg = _('Unable to find group: %(group)s')
126
raise CoraidESMException(msg % dict(group=group))
129
def _get_group_id(self, groupName, loginResult):
130
"""Map group name to group ID."""
131
# NOTE(lmatter): All other groups are under the admin group
132
fullName = "admin group:%s" % groupName
134
for kid in loginResult['values']:
135
fullPath = kid['fullPath']
136
if fullPath == fullName:
137
return kid['groupId']
140
def _esm_cmd(self, url=False, data=None):
142
return self._admin_esm_cmd(url, data)
144
def _admin_esm_cmd(self, url=False, data=None):
146
_admin_esm_cmd represent the entry point to send requests to ESM
147
Appliance. Send the HTTPS call, get response in JSON
148
convert response into Python Object and return it.
153
req = urllib2.Request(url, data)
156
res = self.urlOpener.open(req).read()
158
raise CoraidRESTException(_('ESM urlOpen error'))
161
res_json = jsonutils.loads(res)
163
raise CoraidRESTException(_('JSON Error'))
167
raise CoraidRESTException(_('Request without URL'))
169
def _check_esm_alive(self):
171
url = self.url + 'fetch'
172
req = urllib2.Request(url)
173
code = self.urlOpener.open(req).getcode()
180
def _configure(self, data):
181
"""In charge of all commands into 'configure'."""
183
LOG.debug(_('Configure data : %s'), data)
184
response = self._esm_cmd(url, data)
185
LOG.debug(_("Configure response : %s"), response)
187
if response.get('configState') == 'completedSuccessfully':
190
errmsg = response.get('message', '')
191
msg = _('Message : %(message)s')
192
raise CoraidESMException(msg % dict(message=errmsg))
195
def _get_volume_info(self, volume_name):
196
"""Retrive volume informations for a given volume name."""
197
url = 'fetch?shelf=cms&orchStrRepo&lv=%s' % (volume_name)
199
response = self._esm_cmd(url)
200
info = response[0][1]['reply'][0]
201
return {"pool": info['lv']['containingPool'],
202
"repo": info['repoName'],
203
"vsxidx": info['lv']['lunIndex'],
204
"index": info['lv']['lvStatus']['exportedLun']['lun'],
205
"shelf": info['lv']['lvStatus']['exportedLun']['shelf']}
207
msg = _('Unable to retrive volume infos for volume %(volname)s')
208
raise CoraidESMException(msg % dict(volname=volume_name))
210
def _get_lun_address(self, volume_name):
211
"""Return AoE Address for a given Volume."""
212
volume_info = self._get_volume_info(volume_name)
213
shelf = volume_info['shelf']
214
lun = volume_info['index']
215
return {'shelf': shelf, 'lun': lun}
217
def create_lun(self, volume_name, volume_size, repository):
218
"""Create LUN on Coraid Backend Storage."""
219
data = '[{"addr":"cms","data":"{' \
220
'\\"servers\\":[\\"\\"],' \
221
'\\"repoName\\":\\"%s\\",' \
222
'\\"size\\":\\"%sG\\",' \
223
'\\"lvName\\":\\"%s\\"}",' \
224
'"op":"orchStrLun",' \
225
'"args":"add"}]' % (repository, volume_size,
227
return self._configure(data)
163
Perform login request and return available groups.
165
:returns: dict -- map with group_name to group_id
167
ADMIN_GROUP_PREFIX = 'admin group:'
169
url_params = {'op': 'login',
170
'username': self._username,
171
'password': self._password}
172
reply = self._rest_client.rpc('admin', url_params, 'Login')
173
if reply['state'] != 'adminSucceed':
174
raise exception.CoraidESMBadCredentials()
176
# Read groups map from login reply.
178
for group_info in reply.get('values', []):
179
full_group_name = group_info['fullPath']
180
if full_group_name.startswith(ADMIN_GROUP_PREFIX):
181
group_name = full_group_name[len(ADMIN_GROUP_PREFIX):]
182
groups_map[group_name] = group_info['groupId']
186
def _set_effective_group(self, groups_map, group):
187
"""Set effective group.
189
Use groups_map returned from _login method.
192
group_id = groups_map[group]
194
raise exception.CoraidESMBadGroup(group_name=group)
196
url_params = {'op': 'setRbacGroup',
198
reply = self._rest_client.rpc('admin', url_params, 'Group')
199
if reply['state'] != 'adminSucceed':
200
raise exception.CoraidESMBadCredentials()
204
def _ensure_session(self):
205
if not self._logined:
206
groups_map = self._login()
207
self._set_effective_group(groups_map, self._group)
210
self._logined = False
211
self._ensure_session()
213
def rpc(self, handle, url_params, data, allow_empty_response=False):
214
self._ensure_session()
217
# Do action, relogin if needed and repeat action.
219
reply = self._rest_client.rpc(handle, url_params, data,
220
allow_empty_response)
222
if self._is_session_expired(reply):
223
relogin_attempts -= 1
224
if relogin_attempts <= 0:
225
raise exception.CoraidESMReloginFailed()
226
LOG.debug(_('Session is expired. Relogin on ESM.'))
231
def _is_session_expired(self, reply):
232
return ('state' in reply and
233
reply['state'] in ESM_SESSION_EXPIRED_STATES and
234
reply['metaCROp'] == 'reboot')
236
def _is_bad_config_state(self, reply):
238
'configState' not in reply or
239
reply['configState'] != 'completedSuccessfully')
241
def configure(self, json_request):
242
reply = self.rpc('configure', {}, json_request)
243
if self._is_bad_config_state(reply):
244
# Calculate error message
246
message = _('Reply is empty.')
248
message = reply.get('message', _('Error message is empty.'))
249
raise exception.CoraidESMConfigureError(message=message)
252
def esm_command(self, request):
253
request['data'] = jsonutils.dumps(request['data'])
254
return self.configure([request])
256
def get_volume_info(self, volume_name):
257
"""Retrieve volume information for a given volume name."""
258
url_params = {'shelf': 'cms',
261
reply = self.rpc('fetch', url_params, None)
263
volume_info = reply[0][1]['reply'][0]
264
except (IndexError, KeyError):
265
raise exception.VolumeNotFound(volume_id=volume_name)
266
return {'pool': volume_info['lv']['containingPool'],
267
'repo': volume_info['repoName'],
268
'lun': volume_info['lv']['lvStatus']['exportedLun']['lun'],
269
'shelf': volume_info['lv']['lvStatus']['exportedLun']['shelf']}
271
def get_volume_repository(self, volume_name):
272
volume_info = self.get_volume_info(volume_name)
273
return volume_info['repo']
275
def get_all_repos(self):
276
reply = self.rpc('fetch', {'orchStrRepo': ''}, None)
278
return reply[0][1]['reply']
279
except (IndexError, KeyError):
284
self.rpc('fetch', {}, None, allow_empty_response=True)
285
except Exception as e:
286
LOG.debug(_('Coraid Appliance ping failed: %s'), str(e))
287
raise exception.CoraidESMNotAvailable(reason=str(e))
289
def create_lun(self, repository_name, volume_name, volume_size_in_gb):
290
request = {'addr': 'cms',
293
'repoName': repository_name,
294
'lvName': volume_name,
295
'size': coraid_volume_size(volume_size_in_gb)},
298
esm_result = self.esm_command(request)
299
LOG.debug(_('Volume "%(name)s" created with VSX LUN "%(lun)s"') %
300
{'name': volume_name,
301
'lun': esm_result['firstParam']})
229
304
def delete_lun(self, volume_name):
232
volume_info = self._get_volume_info(volume_name)
233
repository = volume_info['repo']
234
data = '[{"addr":"cms","data":"{' \
235
'\\"repoName\\":\\"%(repo)s\\",' \
236
'\\"lvName\\":\\"%(volname)s\\"}",' \
237
'"op":"orchStrLun/verified",' \
238
'"args":"delete"}]' % dict(repo=repository,
240
return self._configure(data)
242
if self._check_esm_alive():
305
repository_name = self.get_volume_repository(volume_name)
306
request = {'addr': 'cms',
308
'repoName': repository_name,
309
'lvName': volume_name},
310
'op': 'orchStrLun/verified',
312
esm_result = self.esm_command(request)
313
LOG.debug(_('Volume "%s" deleted.'), volume_name)
316
def resize_volume(self, volume_name, new_volume_size_in_gb):
317
LOG.debug(_('Resize volume "%(name)s" to %(size)s') %
318
{'name': volume_name,
319
'size': new_volume_size_in_gb})
320
repository = self.get_volume_repository(volume_name)
321
LOG.debug(_('Repository for volume "%(name)s" found: "%(repo)s"') %
322
{'name': volume_name,
325
request = {'addr': 'cms',
327
'lvName': volume_name,
328
'newLvName': volume_name + '-resize',
329
'size': coraid_volume_size(new_volume_size_in_gb),
330
'repoName': repository},
331
'op': 'orchStrLunMods',
333
esm_result = self.esm_command(request)
335
LOG.debug(_('Volume "%(name)s" resized. New size is %(size)s') %
336
{'name': volume_name,
337
'size': new_volume_size_in_gb})
247
340
def create_snapshot(self, volume_name, snapshot_name):
248
"""Create Snapshot."""
249
volume_info = self._get_volume_info(volume_name)
250
repository = volume_info['repo']
251
data = '[{"addr":"cms","data":"{' \
252
'\\"repoName\\":\\"%s\\",' \
253
'\\"lvName\\":\\"%s\\",' \
254
'\\"newLvName\\":\\"%s\\"}",' \
255
'"op":"orchStrLunMods",' \
256
'"args":"addClSnap"}]' % (repository, volume_name,
258
return self._configure(data)
341
volume_repository = self.get_volume_repository(volume_name)
342
request = {'addr': 'cms',
344
'repoName': volume_repository,
345
'lvName': volume_name,
346
'newLvName': snapshot_name},
347
'op': 'orchStrLunMods',
349
esm_result = self.esm_command(request)
260
352
def delete_snapshot(self, snapshot_name):
261
"""Delete Snapshot."""
262
snapshot_info = self._get_volume_info(snapshot_name)
263
repository = snapshot_info['repo']
264
data = '[{"addr":"cms","data":"{' \
265
'\\"repoName\\":\\"%s\\",' \
266
'\\"lvName\\":\\"%s\\"}",' \
267
'"op":"orchStrLunMods",' \
268
'"args":"delClSnap"}]' % (repository, snapshot_name)
269
return self._configure(data)
271
def create_volume_from_snapshot(self, snapshot_name,
272
volume_name, repository):
273
"""Create a LUN from a Snapshot."""
274
snapshot_info = self._get_volume_info(snapshot_name)
275
snapshot_repo = snapshot_info['repo']
276
data = '[{"addr":"cms","data":"{' \
277
'\\"lvName\\":\\"%s\\",' \
278
'\\"repoName\\":\\"%s\\",' \
279
'\\"newLvName\\":\\"%s\\",' \
280
'\\"newRepoName\\":\\"%s\\"}",' \
281
'"op":"orchStrLunMods",' \
282
'"args":"addClone"}]' % (snapshot_name, snapshot_repo,
283
volume_name, repository)
284
return self._configure(data)
286
def resize_volume(self, volume_name, volume_size):
287
volume_info = self._get_volume_info(volume_name)
288
repository = volume_info['repo']
289
data = '[{"addr":"cms","data":"{' \
290
'\\"lvName\\":\\"%s\\",' \
291
'\\"newLvSize\\":\\"%s\\"}",' \
292
'\\"repoName\\":\\"%s\\"}",' \
293
'"op":"orchStrLunMods",' \
294
'"args":"resizeVolume"}]' % (volume_name,
297
return self._configure(data)
353
repository_name = self.get_volume_repository(snapshot_name)
354
request = {'addr': 'cms',
356
'repoName': repository_name,
357
'lvName': snapshot_name},
358
'op': 'orchStrLunMods',
360
esm_result = self.esm_command(request)
363
def create_volume_from_snapshot(self,
366
dest_repository_name):
367
snapshot_repo = self.get_volume_repository(snapshot_name)
368
request = {'addr': 'cms',
370
'lvName': snapshot_name,
371
'repoName': snapshot_repo,
372
'newLvName': volume_name,
373
'newRepoName': dest_repository_name},
374
'op': 'orchStrLunMods',
376
esm_result = self.esm_command(request)
379
def clone_volume(self,
382
dst_repository_name):
383
src_volume_info = self.get_volume_info(src_volume_name)
385
if src_volume_info['repo'] != dst_repository_name:
386
raise exception.CoraidException(
387
_('Cannot create clone volume in different repository.'))
389
request = {'addr': 'cms',
391
'shelfLun': '{0}.{1}'.format(src_volume_info['shelf'],
392
src_volume_info['lun']),
393
'lvName': src_volume_name,
394
'repoName': src_volume_info['repo'],
395
'newLvName': dst_volume_name,
396
'newRepoName': dst_repository_name},
397
'op': 'orchStrLunMods',
399
return self.esm_command(request)
300
402
class CoraidDriver(driver.VolumeDriver):
301
403
"""This is the Class to set in cinder.conf (volume_driver)."""
303
407
def __init__(self, *args, **kwargs):
304
408
super(CoraidDriver, self).__init__(*args, **kwargs)
305
409
self.configuration.append_config_values(coraid_opts)
307
def do_setup(self, context):
308
"""Initialize the volume driver."""
309
self.esm = CoraidRESTClient(self.configuration.coraid_esm_address,
310
self.configuration.coraid_user,
311
self.configuration.coraid_group,
312
self.configuration.coraid_password)
411
self._stats = {'driver_version': self.VERSION,
412
'free_capacity_gb': 'unknown',
413
'reserved_percentage': 0,
414
'storage_protocol': 'aoe',
415
'total_capacity_gb': 'unknown',
416
'vendor_name': 'Coraid'}
417
backend_name = self.configuration.safe_get('volume_backend_name')
418
self._stats['volume_backend_name'] = backend_name or 'EtherCloud ESM'
422
# NOTE(nsobolevsky): This is workaround for bug in the ESM appliance.
423
# If there is a lot of request with the same session/cookie/connection,
424
# the appliance could corrupt all following request in session.
425
# For that purpose we just create a new appliance.
426
esm_url = "https://{0}:8443".format(
427
self.configuration.coraid_esm_address)
429
return CoraidAppliance(CoraidRESTClient(esm_url),
430
self.configuration.coraid_user,
431
self.configuration.coraid_password,
432
self.configuration.coraid_group)
314
434
def check_for_setup_error(self):
315
435
"""Return an error if prerequisites aren't met."""
316
if not self.esm._login():
317
raise LookupError(_("Cannot login on Coraid ESM"))
436
self.appliance.ping()
319
438
def _get_repository(self, volume_type):
321
Return the ESM Repository from the Volume Type.
439
"""Get the ESM Repository from the Volume Type.
322
441
The ESM Repository is stored into a volume_type_extra_specs key.
324
443
volume_type_id = volume_type['id']
325
444
repository_key_name = self.configuration.coraid_repository_key
326
445
repository = volume_types.get_volume_type_extra_specs(
327
446
volume_type_id, repository_key_name)
447
# Remove <in> keyword from repository name if needed
448
if repository.startswith('<in> '):
449
return repository[len('<in> '):]
330
453
def create_volume(self, volume):
331
454
"""Create a Volume."""
333
repository = self._get_repository(volume['volume_type'])
334
self.esm.create_lun(volume['name'], volume['size'], repository)
336
msg = _('Fail to create volume %(volname)s')
337
LOG.debug(msg % dict(volname=volume['name']))
339
# NOTE(jbr_): The manager currently interprets any return as
340
# being the model_update for provider location.
341
# return None to not break it (thank to jgriffith and DuncanT)
455
repository = self._get_repository(volume['volume_type'])
456
self.appliance.create_lun(repository, volume['name'], volume['size'])
458
def create_cloned_volume(self, volume, src_vref):
459
dst_volume_repository = self._get_repository(volume['volume_type'])
461
self.appliance.clone_volume(src_vref['name'],
463
dst_volume_repository)
465
if volume['size'] != src_vref['size']:
466
self.appliance.resize_volume(volume['name'], volume['size'])
344
468
def delete_volume(self, volume):
345
469
"""Delete a Volume."""
347
self.esm.delete_lun(volume['name'])
349
msg = _('Failed to delete volume %(volname)s')
350
LOG.debug(msg % dict(volname=volume['name']))
471
self.appliance.delete_lun(volume['name'])
472
except exception.VolumeNotFound:
473
self.appliance.ping()
354
475
def create_snapshot(self, snapshot):
355
476
"""Create a Snapshot."""
356
volume_name = (self.configuration.volume_name_template
357
% snapshot['volume_id'])
358
snapshot_name = (self.configuration.snapshot_name_template
361
self.esm.create_snapshot(volume_name, snapshot_name)
362
except Exception as e:
363
msg = _('Failed to Create Snapshot %(snapname)s')
364
LOG.debug(msg % dict(snapname=snapshot_name))
477
volume_name = snapshot['volume_name']
478
snapshot_name = snapshot['name']
479
self.appliance.create_snapshot(volume_name, snapshot_name)
368
481
def delete_snapshot(self, snapshot):
369
482
"""Delete a Snapshot."""
370
snapshot_name = (self.configuration.snapshot_name_template
373
self.esm.delete_snapshot(snapshot_name)
375
msg = _('Failed to Delete Snapshot %(snapname)s')
376
LOG.debug(msg % dict(snapname=snapshot_name))
483
snapshot_name = snapshot['name']
484
self.appliance.delete_snapshot(snapshot_name)
380
486
def create_volume_from_snapshot(self, volume, snapshot):
381
487
"""Create a Volume from a Snapshot."""
382
snapshot_name = (self.configuration.snapshot_name_template
488
snapshot_name = snapshot['name']
384
489
repository = self._get_repository(volume['volume_type'])
386
self.esm.create_volume_from_snapshot(snapshot_name,
389
resize = volume['size'] > snapshot['volume_size']
391
self.esm.resize_volume(volume['name'], volume['size'])
393
msg = _('Failed to Create Volume from Snapshot %(snapname)s')
394
LOG.debug(msg % dict(snapname=snapshot_name))
490
self.appliance.create_volume_from_snapshot(snapshot_name,
493
if volume['size'] > snapshot['volume_size']:
494
self.appliance.resize_volume(volume['name'], volume['size'])
398
496
def extend_volume(self, volume, new_size):
399
"""Extend an Existing Volume."""
401
self.esm.resize_volume(volume['name'], new_size)
403
msg = _('Failed to Extend Volume %(volname)s')
404
LOG.debug(msg % dict(volname=volume['name']))
497
"""Extend an existing volume."""
498
self.appliance.resize_volume(volume['name'], new_size)
408
500
def initialize_connection(self, volume, connector):
409
501
"""Return connection information."""
411
infos = self.esm._get_lun_address(volume['name'])
412
shelf = infos['shelf']
416
'target_shelf': shelf,
420
'driver_volume_type': 'aoe',
421
'data': aoe_properties,
424
msg = _('Failed to Initialize Connection. '
425
'Volume Name: %(volname)s '
428
LOG.debug(msg % dict(volname=volume['name'],
502
volume_info = self.appliance.get_volume_info(volume['name'])
504
shelf = volume_info['shelf']
505
lun = volume_info['lun']
507
LOG.debug(_('Initialize connection %(shelf)s/%(lun)s for %(name)s') %
510
'name': volume['name']})
512
aoe_properties = {'target_shelf': shelf,
515
return {'driver_volume_type': 'aoe',
516
'data': aoe_properties}
518
def _get_repository_capabilities(self):
519
repos_list = map(lambda i: i['profile']['fullName'] + ':' + i['name'],
520
self.appliance.get_all_repos())
521
return ' '.join(repos_list)
523
def update_volume_stats(self):
524
capabilities = self._get_repository_capabilities()
525
self._stats[self.configuration.coraid_repository_key] = capabilities
434
527
def get_volume_stats(self, refresh=False):
435
528
"""Return Volume Stats."""
436
data = {'driver_version': '1.0',
437
'free_capacity_gb': 'unknown',
438
'reserved_percentage': 0,
439
'storage_protocol': 'aoe',
440
'total_capacity_gb': 'unknown',
441
'vendor_name': 'Coraid'}
442
backend_name = self.configuration.safe_get('volume_backend_name')
443
data['volume_backend_name'] = backend_name or 'EtherCloud ESM'
530
self.update_volume_stats()
446
533
def local_path(self, volume):