Coverage for manila/share/drivers/helpers.py: 96%
311 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2026-02-18 22:19 +0000
1# Copyright 2015 Mirantis Inc.
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
16import copy
17import ipaddress
18import os
19import re
21from oslo_log import log
23from manila.common import constants as const
24from manila import exception
25from manila.i18n import _
26from manila import utils
28LOG = log.getLogger(__name__)
31class NASHelperBase(object):
32 """Interface to work with share."""
34 def __init__(self, execute, ssh_execute, config_object):
35 self.configuration = config_object
36 self._execute = execute
37 self._ssh_exec = ssh_execute
39 def init_helper(self, server):
40 pass
42 def create_exports(self, server, share_name, recreate=False):
43 """Create new exports, delete old ones if exist."""
44 raise NotImplementedError()
46 def remove_exports(self, server, share_name):
47 """Remove exports."""
48 raise NotImplementedError()
50 def configure_access(self, server, share_name):
51 """Configure server before allowing access."""
52 pass
54 def update_access(self, server, share_name, access_rules, add_rules,
55 delete_rules):
56 """Update access rules for given share.
58 This driver has two different behaviors according to parameters:
59 1. Recovery after error - 'access_rules' contains all access_rules,
60 'add_rules' and 'delete_rules' shall be empty. Previously existing
61 access rules are cleared and then added back according
62 to 'access_rules'.
64 2. Adding/Deleting of several access rules - 'access_rules' contains
65 all access_rules, 'add_rules' and 'delete_rules' contain rules which
66 should be added/deleted. Rules in 'access_rules' are ignored and
67 only rules from 'add_rules' and 'delete_rules' are applied.
69 :param server: None or Share server's backend details
70 :param share_name: Share's path according to id.
71 :param access_rules: All access rules for given share
72 :param add_rules: Empty List or List of access rules which should be
73 added. access_rules already contains these rules.
74 :param delete_rules: Empty List or List of access rules which should be
75 removed. access_rules doesn't contain these rules.
76 """
77 raise NotImplementedError()
79 @staticmethod
80 def _verify_server_has_public_address(server):
81 if 'public_address' in server:
82 pass
83 elif 'public_addresses' in server:
84 if not isinstance(server['public_addresses'], list): 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true
85 raise exception.ManilaException(_("public_addresses must be "
86 "a list"))
87 else:
88 raise exception.ManilaException(
89 _("Can not get public_address(es) for generation of export."))
91 def _get_export_location_template(self, export_location_or_path):
92 """Returns template of export location.
94 Example for NFS:
95 %s:/path/to/share
96 Example for CIFS:
97 \\\\%s\\cifs_share_name
98 """
99 raise NotImplementedError()
101 def get_exports_for_share(self, server, export_location_or_path):
102 """Returns list of exports based on server info."""
103 self._verify_server_has_public_address(server)
104 export_location_template = self._get_export_location_template(
105 export_location_or_path)
106 export_locations = []
108 if 'public_addresses' in server:
109 pairs = list(map(lambda addr: (addr, False),
110 server['public_addresses']))
111 else:
112 pairs = [(server['public_address'], False)]
114 # NOTE(vponomaryov):
115 # Generic driver case: 'admin_ip' exists only in case of DHSS=True
116 # mode and 'ip' exists in case of DHSS=False mode.
117 # Use one of these for creation of export location for service needs.
118 service_address = server.get("admin_ip", server.get("ip"))
119 if service_address:
120 pairs.append((service_address, True))
121 for ip, is_admin in pairs:
122 export_locations.append({
123 "path": export_location_template % ip,
124 "is_admin_only": is_admin,
125 "metadata": {
126 # TODO(vponomaryov): remove this fake metadata when
127 # proper appears.
128 "export_location_metadata_example": "example",
129 },
130 })
131 return export_locations
133 def get_share_path_by_export_location(self, server, export_location):
134 """Returns share path by its export location."""
135 raise NotImplementedError()
137 def disable_access_for_maintenance(self, server, share_name):
138 """Disables access to share to perform maintenance operations."""
140 def restore_access_after_maintenance(self, server, share_name):
141 """Enables access to share after maintenance operations were done."""
143 @staticmethod
144 def validate_access_rules(access_rules, allowed_types, allowed_levels):
145 """Validates access rules according to access_type and access_level.
147 :param access_rules: List of access rules to be validated.
148 :param allowed_types: tuple of allowed type values.
149 :param allowed_levels: tuple of allowed level values.
150 """
151 for access in (access_rules or []):
152 access_type = access['access_type']
153 access_level = access['access_level']
154 if access_type not in allowed_types:
155 reason = _("Only %s access type allowed.") % (
156 ', '.join(tuple(["'%s'" % x for x in allowed_types])))
157 raise exception.InvalidShareAccess(reason=reason)
158 if access_level not in allowed_levels:
159 raise exception.InvalidShareAccessLevel(level=access_level)
161 def _get_maintenance_file_path(self, share_name):
162 return os.path.join(self.configuration.share_mount_path,
163 "%s.maintenance" % share_name)
166def nfs_synchronized(f):
168 def wrapped_func(self, *args, **kwargs):
169 key = "nfs-%s" % args[0].get("lock_name", args[0]["instance_id"])
171 # NOTE(vponomaryov): 'external' lock is required for DHSS=False
172 # mode of LVM and Generic drivers, that may have lots of
173 # driver instances on single host.
174 @utils.synchronized(key, external=True)
175 def source_func(self, *args, **kwargs):
176 return f(self, *args, **kwargs)
178 return source_func(self, *args, **kwargs)
180 return wrapped_func
183def escaped_address(address):
184 addr = ipaddress.ip_address(str(address))
185 if addr.version == 4:
186 return str(addr)
187 else:
188 return '[%s]' % addr
191class NFSHelper(NASHelperBase):
192 """Interface to work with share."""
194 def create_exports(self, server, share_name, recreate=False):
195 path = os.path.join(self.configuration.share_mount_path, share_name)
196 server_copy = copy.copy(server)
197 public_addresses = []
198 if 'public_addresses' in server_copy:
199 for address in server_copy['public_addresses']:
200 public_addresses.append(
201 escaped_address(address))
202 server_copy['public_addresses'] = public_addresses
204 for t in ['public_address', 'admin_ip', 'ip']:
205 address = server_copy.get(t)
206 if address is not None:
207 server_copy[t] = escaped_address(address)
209 return self.get_exports_for_share(server_copy, path)
211 def init_helper(self, server):
212 try:
213 self._ssh_exec(server, ['sudo', 'exportfs'])
214 except exception.ProcessExecutionError as e:
215 if 'command not found' in e.stderr:
216 raise exception.ManilaException(
217 _('NFS server is not installed on %s')
218 % server['instance_id'])
219 LOG.error(e.stderr)
221 def remove_exports(self, server, share_name):
222 """Remove exports."""
224 @nfs_synchronized
225 def update_access(self, server, share_name, access_rules, add_rules,
226 delete_rules):
227 """Update access rules for given share.
229 Please refer to base class for a more in-depth description.
230 """
231 local_path = os.path.join(self.configuration.share_mount_path,
232 share_name)
233 out, err = self._ssh_exec(server, ['sudo', 'exportfs'])
234 # Recovery mode
235 if not (add_rules or delete_rules):
237 self.validate_access_rules(
238 access_rules, ('ip',),
239 (const.ACCESS_LEVEL_RO, const.ACCESS_LEVEL_RW))
241 hosts = self.get_host_list(out, local_path)
242 for host in hosts:
243 parsed_host = self._get_parsed_address_or_cidr(host)
244 self._ssh_exec(server, ['sudo', 'exportfs', '-u',
245 ':'.join((parsed_host, local_path))])
246 self._sync_nfs_temp_and_perm_files(server)
247 for access in access_rules:
248 rules_options = '%s,no_subtree_check,no_root_squash'
249 access_to = self._get_parsed_address_or_cidr(
250 access['access_to'])
251 self._ssh_exec(
252 server,
253 ['sudo', 'exportfs', '-o',
254 rules_options % access['access_level'],
255 ':'.join((access_to, local_path))])
256 self._sync_nfs_temp_and_perm_files(server)
257 # Adding/Deleting specific rules
258 else:
260 self.validate_access_rules(
261 add_rules, ('ip',),
262 (const.ACCESS_LEVEL_RO, const.ACCESS_LEVEL_RW))
264 for access in delete_rules:
265 try:
266 self.validate_access_rules(
267 [access], ('ip',),
268 (const.ACCESS_LEVEL_RO, const.ACCESS_LEVEL_RW))
269 except (exception.InvalidShareAccess,
270 exception.InvalidShareAccessLevel):
271 LOG.warning(
272 "Unsupported access level %(level)s or access type "
273 "%(type)s, skipping removal of access rule to "
274 "%(to)s.", {'level': access['access_level'],
275 'type': access['access_type'],
276 'to': access['access_to']})
277 continue
278 access_to = self._get_parsed_address_or_cidr(
279 access['access_to'])
280 try:
281 self._ssh_exec(server, ['sudo', 'exportfs', '-u',
282 ':'.join((access_to, local_path))])
283 except exception.ProcessExecutionError as e:
284 if "could not find" in e.stderr.lower(): 284 ↛ 290line 284 didn't jump to line 290 because the condition on line 284 was always true
285 LOG.debug(
286 "Client/s with IP address/es %(host)s did not "
287 "have access to %(share)s. Nothing to deny.",
288 {'host': access_to, 'share': share_name})
289 else:
290 raise
292 if delete_rules: 292 ↛ 294line 292 didn't jump to line 294 because the condition on line 292 was always true
293 self._sync_nfs_temp_and_perm_files(server)
294 for access in add_rules:
295 access_to = self._get_parsed_address_or_cidr(
296 access['access_to'])
297 found_item = re.search(
298 re.escape(local_path) + r'[\s\n]*' + re.escape(access_to),
299 out)
300 if found_item is not None:
301 LOG.warning("Access rule %(type)s:%(to)s already "
302 "exists for share %(name)s", {
303 'to': access['access_to'],
304 'type': access['access_type'],
305 'name': share_name
306 })
307 else:
308 rules_options = '%s,no_subtree_check,no_root_squash'
309 self._ssh_exec(
310 server,
311 ['sudo', 'exportfs', '-o',
312 rules_options % access['access_level'],
313 ':'.join((access_to, local_path))])
314 if add_rules:
315 self._sync_nfs_temp_and_perm_files(server)
317 @staticmethod
318 def _get_parsed_address_or_cidr(access_to):
319 network = ipaddress.ip_network(str(access_to))
320 mask_length = network.prefixlen
321 address = str(network.network_address)
322 if mask_length == 0:
323 # Special case because Linux exports don't support /0 netmasks
324 return '*'
325 if network.version == 4:
326 if mask_length == 32:
327 return address
328 return '%s/%s' % (address, mask_length)
329 if mask_length == 128:
330 return "[%s]" % address
331 return "[%s]/%s" % (address, mask_length)
333 @staticmethod
334 def get_host_list(output, local_path):
335 entries = []
336 output = output.replace('\n\t\t', ' ')
337 lines = output.split('\n')
338 for line in lines:
339 items = line.split(' ')
340 if local_path == items[0]:
341 entries.append(items[1])
342 # exportfs may print"<world>" instead of "*" for host
343 entries = ["*" if item == "<world>" else item for item in entries]
344 return entries
346 def _sync_nfs_temp_and_perm_files(self, server):
347 """Sync changes of exports with permanent NFS config file.
349 This is required to ensure, that after share server reboot, exports
350 still exist.
351 """
352 sync_cmd = [
353 'sudo', 'cp', const.NFS_EXPORTS_FILE_TEMP, const.NFS_EXPORTS_FILE
354 ]
355 self._ssh_exec(server, sync_cmd)
356 self._ssh_exec(server, ['sudo', 'exportfs', '-a'])
357 out, _ = self._ssh_exec(
358 server,
359 ['sudo', 'systemctl', 'is-active', 'nfs-kernel-server'],
360 check_exit_code=False)
361 if "inactive" in out: 361 ↛ 362line 361 didn't jump to line 362 because the condition on line 361 was never true
362 self._ssh_exec(
363 server, ['sudo', 'systemctl', 'restart', 'nfs-kernel-server'])
365 def _get_export_location_template(self, export_location_or_path):
366 path = export_location_or_path.split(':')[-1]
367 return '%s:' + path
369 def get_share_path_by_export_location(self, server, export_location):
370 return export_location.split(':')[-1]
372 @nfs_synchronized
373 def disable_access_for_maintenance(self, server, share_name):
374 maintenance_file = self._get_maintenance_file_path(share_name)
375 backup_exports = [
376 'cat', const.NFS_EXPORTS_FILE,
377 '|', 'grep', share_name,
378 '|', 'sudo', 'tee', maintenance_file
379 ]
380 self._ssh_exec(server, backup_exports)
382 local_path = os.path.join(self.configuration.share_mount_path,
383 share_name)
384 out, err = self._ssh_exec(server, ['sudo', 'exportfs'])
385 hosts = self.get_host_list(out, local_path)
386 for host in hosts:
387 self._ssh_exec(server,
388 ['sudo', 'exportfs', '-u',
389 '"{}"'.format(':'.join((host, local_path)))])
390 self._sync_nfs_temp_and_perm_files(server)
392 @nfs_synchronized
393 def restore_access_after_maintenance(self, server, share_name):
394 maintenance_file = self._get_maintenance_file_path(share_name)
395 restore_exports = [
396 'cat', maintenance_file,
397 '|', 'sudo', 'tee', '-a', const.NFS_EXPORTS_FILE,
398 '&&', 'sudo', 'exportfs', '-r',
399 '&&', 'sudo', 'rm', '-f', maintenance_file
400 ]
401 self._ssh_exec(server, restore_exports)
404class CIFSHelperBase(NASHelperBase):
405 @staticmethod
406 def _get_share_group_name_from_export_location(export_location):
407 if '/' in export_location and '\\' in export_location:
408 pass
409 elif export_location.startswith('\\\\'):
410 return export_location.split('\\')[-1]
411 elif export_location.startswith('//'):
412 return export_location.split('/')[-1]
414 msg = _("Got incorrect CIFS export location '%s'.") % export_location
415 raise exception.InvalidShare(reason=msg)
417 def _get_export_location_template(self, export_location_or_path):
418 group_name = self._get_share_group_name_from_export_location(
419 export_location_or_path)
420 return ('\\\\%s' + ('\\%s' % group_name))
423class CIFSHelperIPAccess(CIFSHelperBase):
424 """Manage shares in samba server by net conf tool.
426 Class provides functionality to operate with CIFS shares.
427 Samba server should be configured to use registry as configuration
428 backend to allow dynamically share managements. This class allows
429 to define access to shares by IPs with RW access level.
430 """
431 def __init__(self, *args):
432 super(CIFSHelperIPAccess, self).__init__(*args)
433 self.parameters = {
434 'browseable': 'yes',
435 'create mask': '0755',
436 'hosts deny': '0.0.0.0/0', # deny all by default
437 'hosts allow': '127.0.0.1',
438 'read only': 'no',
439 }
441 def init_helper(self, server):
442 # This is smoke check that we have required dependency
443 self._ssh_exec(server, ['sudo', 'net', 'conf', 'list'])
445 def create_exports(self, server, share_name, recreate=False):
446 """Create share at samba server."""
447 share_path = os.path.join(self.configuration.share_mount_path,
448 share_name)
449 create_cmd = [
450 'sudo', 'net', 'conf', 'addshare', share_name, share_path,
451 'writeable=y', 'guest_ok=y',
452 ]
453 try:
454 self._ssh_exec(
455 server, ['sudo', 'net', 'conf', 'showshare', share_name, ])
456 except exception.ProcessExecutionError:
457 # Share does not exist, create it
458 try:
459 self._ssh_exec(server, create_cmd)
460 except Exception:
461 msg = _("Could not create CIFS export %s.") % share_name
462 LOG.exception(msg)
463 raise exception.ManilaException(msg)
464 else:
465 # Share exists
466 if recreate:
467 self._ssh_exec(
468 server, ['sudo', 'net', 'conf', 'delshare', share_name, ])
469 try:
470 self._ssh_exec(server, create_cmd)
471 except Exception:
472 msg = _("Could not create CIFS export %s.") % share_name
473 LOG.exception(msg)
474 raise exception.ManilaException(msg)
475 else:
476 msg = _('Share section %s already defined.') % share_name
477 raise exception.ShareBackendException(msg=msg)
479 for param, value in self.parameters.items():
480 self._ssh_exec(server, ['sudo', 'net', 'conf', 'setparm',
481 share_name, param, value])
483 return self.get_exports_for_share(server, '\\\\%s\\' + share_name)
485 def remove_exports(self, server, share_name):
486 """Remove share definition from samba server."""
487 try:
488 self._ssh_exec(
489 server, ['sudo', 'net', 'conf', 'delshare', share_name])
490 except exception.ProcessExecutionError as e:
491 LOG.warning("Caught error trying delete share: %(error)s, try"
492 "ing delete it forcibly.", {'error': e.stderr})
493 self._ssh_exec(server, ['sudo', 'smbcontrol', 'all', 'close-share',
494 share_name])
496 def update_access(self, server, share_name, access_rules, add_rules,
497 delete_rules):
498 """Update access rules for given share.
500 Please refer to base class for a more in-depth description. For this
501 specific implementation, add_rules and delete_rules parameters are not
502 used.
503 """
504 hosts = []
506 self.validate_access_rules(
507 access_rules, ('ip',), (const.ACCESS_LEVEL_RW,))
509 for access in access_rules:
510 hosts.append(access['access_to'])
511 self._set_allow_hosts(server, hosts, share_name)
513 def _get_allow_hosts(self, server, share_name):
514 (out, _) = self._ssh_exec(server, ['sudo', 'net', 'conf', 'getparm',
515 share_name, 'hosts allow'])
516 return out.split()
518 def _set_allow_hosts(self, server, hosts, share_name):
519 value = ' '.join(hosts) or ' '
520 self._ssh_exec(server, ['sudo', 'net', 'conf', 'setparm', share_name,
521 'hosts allow', value])
523 def get_share_path_by_export_location(self, server, export_location):
524 # Get name of group that contains share data on CIFS server
525 group_name = self._get_share_group_name_from_export_location(
526 export_location)
528 # Get parameter 'path' from group that belongs to current share
529 (out, __) = self._ssh_exec(
530 server, ['sudo', 'net', 'conf', 'getparm', group_name, 'path'])
532 # Remove special symbols from response and return path
533 return out.strip()
535 def disable_access_for_maintenance(self, server, share_name):
536 maintenance_file = self._get_maintenance_file_path(share_name)
537 allowed_hosts = " ".join(self._get_allow_hosts(server, share_name))
539 backup_exports = [
540 'echo', "'%s'" % allowed_hosts, '|', 'sudo', 'tee',
541 maintenance_file
542 ]
543 self._ssh_exec(server, backup_exports)
544 self._set_allow_hosts(server, [], share_name)
545 self._kick_out_users(server, share_name)
547 def _kick_out_users(self, server, share_name):
548 """Kick out all users of share"""
549 (out, _) = self._ssh_exec(server, ['sudo', 'smbstatus', '-S'])
551 shares = []
552 header = True
553 regexp = r"^(?P<share>[^ ]+)\s+(?P<pid>[0-9]+)\s+(?P<machine>[^ ]+).*"
554 for line in out.splitlines():
555 line = line.strip()
556 if not header and line:
557 match = re.match(regexp, line)
558 if match:
559 shares.append(match.groupdict())
560 else:
561 raise exception.ShareBackendException(
562 msg="Failed to obtain smbstatus for %s!" % share_name)
563 elif line.startswith('----'):
564 header = False
565 to_kill = [s['pid'] for s in shares if
566 share_name == s['share'] or share_name is None]
567 if to_kill:
568 self._ssh_exec(server, ['sudo', 'kill', '-15'] + to_kill)
570 def restore_access_after_maintenance(self, server, share_name):
571 maintenance_file = self._get_maintenance_file_path(share_name)
572 (exports, __) = self._ssh_exec(server, ['cat', maintenance_file])
573 self._set_allow_hosts(server, exports.split(), share_name)
574 self._ssh_exec(server, ['sudo', 'rm', '-f', maintenance_file])
577class CIFSHelperUserAccess(CIFSHelperIPAccess):
578 """Manage shares in samba server by net conf tool.
580 Class provides functionality to operate with CIFS shares.
581 Samba server should be configured to use registry as configuration
582 backend to allow dynamically share managements. This class allows
583 to define access to shares by usernames with either RW or RO access levels.
584 """
585 def __init__(self, *args):
586 super(CIFSHelperUserAccess, self).__init__(*args)
587 self.parameters = {
588 'browseable': 'yes',
589 'create mask': '0755',
590 'hosts allow': '0.0.0.0/0',
591 'read only': 'no',
592 }
594 def update_access(self, server, share_name, access_rules, add_rules,
595 delete_rules):
596 """Update access rules for given share.
598 Please refer to base class for a more in-depth description. For this
599 specific implementation, add_rules and delete_rules parameters are not
600 used.
601 """
602 all_users_rw = []
603 all_users_ro = []
605 self.validate_access_rules(
606 access_rules, ('user',),
607 (const.ACCESS_LEVEL_RO, const.ACCESS_LEVEL_RW))
609 for access in access_rules:
610 if access['access_level'] == const.ACCESS_LEVEL_RW:
611 all_users_rw.append(access['access_to'])
612 else:
613 all_users_ro.append(access['access_to'])
614 self._set_valid_users(
615 server, all_users_rw, share_name, const.ACCESS_LEVEL_RW)
616 self._set_valid_users(
617 server, all_users_ro, share_name, const.ACCESS_LEVEL_RO)
619 def _get_conf_param(self, access_level):
620 if access_level == const.ACCESS_LEVEL_RW:
621 return 'valid users'
622 else:
623 return 'read list'
625 def _set_valid_users(self, server, users, share_name, access_level):
626 value = ' '.join(users)
627 param = self._get_conf_param(access_level)
628 self._ssh_exec(server, ['sudo', 'net', 'conf', 'setparm', share_name,
629 param, value])