1# Copyright (c) 2013 OpenStack Foundation
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.
15
16"""Remote filesystem client utilities."""
17
18import os
19import re
20import tempfile
21
22from oslo_concurrency import processutils
23from oslo_log import log as logging
24from oslo_utils.secretutils import md5
25
26from os_brick import exception
27from os_brick import executor
28from os_brick.i18n import _
29
30LOG = logging.getLogger(__name__)
31
32
33class RemoteFsClient(executor.Executor):
34
35    def __init__(self, mount_type, root_helper,
36                 execute=None, *args, **kwargs):
37        super(RemoteFsClient, self).__init__(root_helper, execute=execute,
38                                             *args, **kwargs)  # type: ignore
39
40        mount_type_to_option_prefix = {
41            'nfs': 'nfs',
42            'cifs': 'smbfs',
43            'glusterfs': 'glusterfs',
44            'vzstorage': 'vzstorage',
45            'quobyte': 'quobyte',
46            'scality': 'scality'
47        }
48
49        if mount_type not in mount_type_to_option_prefix:
50            raise exception.ProtocolNotSupported(protocol=mount_type)
51
52        self._mount_type = mount_type
53        option_prefix = mount_type_to_option_prefix[mount_type]
54
55        self._mount_base: str
56        self._mount_base = kwargs.get(option_prefix +
57                                      '_mount_point_base')  # type: ignore
58        if not self._mount_base:
59            raise exception.InvalidParameterValue(
60                err=_('%s_mount_point_base required') % option_prefix)
61
62        self._mount_options = kwargs.get(option_prefix + '_mount_options')
63
64        if mount_type == "nfs":
65            self._check_nfs_options()
66
67    def get_mount_base(self):
68        return self._mount_base
69
70    def _get_hash_str(self, base_str):
71        """Return a string that represents hash of base_str (hex format)."""
72        if isinstance(base_str, str):
73            base_str = base_str.encode('utf-8')
74        return md5(base_str,
75                   usedforsecurity=False).hexdigest()
76
77    def get_mount_point(self, device_name: str):
78        """Get Mount Point.
79
80        :param device_name: example 172.18.194.100:/var/nfs
81        """
82        return os.path.join(self._mount_base,
83                            self._get_hash_str(device_name))
84
85    def _read_mounts(self):
86        """Returns a dict of mounts and their mountpoint
87
88        Format reference:
89        http://man7.org/linux/man-pages/man5/fstab.5.html
90        """
91        with open("/proc/mounts", "r") as mounts:
92            # Remove empty lines and split lines by whitespace
93            lines = [line.split() for line in mounts.read().splitlines()
94                     if line.strip()]
95
96            # Return {mountpoint: mountdevice}.  Fields 2nd and 1st as per
97            # http://man7.org/linux/man-pages/man5/fstab.5.html
98            return {line[1]: line[0] for line in lines if line[0] != '#'}
99
100    def mount(self, share, flags=None):
101        """Mount given share."""
102        mount_path = self.get_mount_point(share)
103
104        if mount_path in self._read_mounts():
105            LOG.debug('Already mounted: %s', mount_path)
106            return
107
108        self._execute('mkdir', '-p', mount_path, check_exit_code=0)
109        if self._mount_type == 'nfs':
110            self._mount_nfs(share, mount_path, flags)
111        else:
112            self._do_mount(self._mount_type, share, mount_path,
113                           self._mount_options, flags)
114
115    def _do_mount(self, mount_type, share, mount_path, mount_options=None,
116                  flags=None):
117        """Mounts share based on the specified params."""
118        mnt_cmd = ['mount', '-t', mount_type]
119        if mount_options is not None:
120            mnt_cmd.extend(['-o', mount_options])
121        if flags is not None:
122            mnt_cmd.extend(flags)
123        mnt_cmd.extend([share, mount_path])
124
125        try:
126            self._execute(*mnt_cmd, root_helper=self._root_helper,
127                          run_as_root=True, check_exit_code=0)
128        except processutils.ProcessExecutionError as exc:
129            if 'already mounted' in exc.stderr:
130                LOG.debug("Already mounted: %s", share)
131
132                # The error message can say "busy or already mounted" when the
133                # share didn't actually mount, so look for it.
134                if share in self._read_mounts():
135                    return
136
137            LOG.error("Failed to mount %(share)s, reason: %(reason)s",
138                      {'share': share, 'reason': exc.stderr})
139            raise
140
141    def _mount_nfs(self, nfs_share, mount_path, flags=None):
142        """Mount nfs share using present mount types."""
143        mnt_errors = {}
144
145        # This loop allows us to first try to mount with NFS 4.1 for pNFS
146        # support but falls back to mount NFS 4 or NFS 3 if either the client
147        # or server do not support it.
148        for mnt_type in sorted(self._nfs_mount_type_opts.keys(), reverse=True):
149            options = self._nfs_mount_type_opts[mnt_type]
150            try:
151                self._do_mount('nfs', nfs_share, mount_path, options, flags)
152                LOG.debug('Mounted %(sh)s using %(mnt_type)s.',
153                          {'sh': nfs_share, 'mnt_type': mnt_type})
154                return
155            except Exception as e:
156                mnt_errors[mnt_type] = str(e)
157                LOG.debug('Failed to do %s mount.', mnt_type)
158        raise exception.BrickException(_("NFS mount failed for share %(sh)s. "
159                                         "Error - %(error)s")
160                                       % {'sh': nfs_share,
161                                          'error': mnt_errors})
162
163    def _check_nfs_options(self):
164        """Checks and prepares nfs mount type options."""
165        self._nfs_mount_type_opts = {'nfs': self._mount_options}
166        nfs_vers_opt_patterns = ['^nfsvers', '^vers', r'^v[\d]']
167        for opt in nfs_vers_opt_patterns:
168            if self._option_exists(self._mount_options, opt):
169                return
170
171        # pNFS requires NFS 4.1. The mount.nfs4 utility does not automatically
172        # negotiate 4.1 support, we have to ask for it by specifying two
173        # options: vers=4 and minorversion=1.
174        pnfs_opts = self._update_option(self._mount_options, 'vers', '4')
175        pnfs_opts = self._update_option(pnfs_opts, 'minorversion', '1')
176        self._nfs_mount_type_opts['pnfs'] = pnfs_opts
177
178    def _option_exists(self, options, opt_pattern):
179        """Checks if the option exists in nfs options and returns position."""
180        options = [x.strip() for x in options.split(',')] if options else []
181        pos = 0
182        for opt in options:
183            pos = pos + 1
184            if re.match(opt_pattern, opt, flags=0):
185                return pos
186        return 0
187
188    def _update_option(self, options, option, value=None):
189        """Update option if exists else adds it and returns new options."""
190        opts = [x.strip() for x in options.split(',')] if options else []
191        pos = self._option_exists(options, option)
192        if pos:
193            opts.pop(pos - 1)
194        opt = '%s=%s' % (option, value) if value else option
195        opts.append(opt)
196        return ",".join(opts) if len(opts) > 1 else opts[0]
197
198
199class ScalityRemoteFsClient(RemoteFsClient):
200    def __init__(self, mount_type, root_helper,
201                 execute=None, *args, **kwargs):
202        super(ScalityRemoteFsClient, self).__init__(
203            mount_type, root_helper, execute=execute,
204            *args, **kwargs)  # type: ignore
205        self._mount_type = mount_type
206        self._mount_base = kwargs.get(
207            'scality_mount_point_base', "").rstrip('/')
208        if not self._mount_base:
209            raise exception.InvalidParameterValue(
210                err=_('scality_mount_point_base required'))
211        self._mount_options = None
212
213    def get_mount_point(self, device_name):
214        return os.path.join(self._mount_base,
215                            device_name,
216                            "00")
217
218    def mount(self, share, flags=None):
219        """Mount the Scality ScaleOut FS.
220
221        The `share` argument is ignored because you can't mount several
222        SOFS at the same type on a single server. But we want to keep the
223        same method signature for class inheritance purpose.
224        """
225        if self._mount_base in self._read_mounts():
226            LOG.debug('Already mounted: %s', self._mount_base)
227            return
228        self._execute('mkdir', '-p', self._mount_base, check_exit_code=0)
229        super(ScalityRemoteFsClient, self)._do_mount(
230            'sofs', '/usr/local/etc/sfused.conf', self._mount_base)
231
232
233class VZStorageRemoteFSClient(RemoteFsClient):
234    def _vzstorage_write_mds_list(self, cluster_name, mdss):
235        tmp_dir = tempfile.mkdtemp(prefix='vzstorage-')
236        tmp_bs_path = os.path.join(tmp_dir, 'bs_list')
237        with open(tmp_bs_path, 'w') as f:
238            for mds in mdss:
239                f.write(mds + "\n")
240
241        conf_dir = os.path.join('/usr/local/etc/pstorage/clusters', cluster_name)
242        if os.path.exists(conf_dir):
243            bs_path = os.path.join(conf_dir, 'bs_list')
244            self._execute('cp', '-f', tmp_bs_path, bs_path,
245                          root_helper=self._root_helper, run_as_root=True)
246        else:
247            self._execute('cp', '-rf', tmp_dir, conf_dir,
248                          root_helper=self._root_helper, run_as_root=True)
249        self._execute('chown', '-R', 'root:root', conf_dir,
250                      root_helper=self._root_helper, run_as_root=True)
251
252    def _do_mount(self, mount_type, vz_share, mount_path,
253                  mount_options=None, flags=None):
254        m = re.search(r"(?:(\S+):\/)?([a-zA-Z0-9_-]+)(?::(\S+))?", vz_share)
255        if not m:
256            msg = (_("Invalid Virtuozzo Storage share specification: %r."
257                     "Must be: [MDS1[,MDS2],...:/]<CLUSTER NAME>[:PASSWORD].")
258                   % vz_share)
259            raise exception.BrickException(msg)
260
261        mdss = m.group(1)
262        cluster_name = m.group(2)
263        passwd = m.group(3)
264
265        if mdss:
266            mdss = mdss.split(',')
267            self._vzstorage_write_mds_list(cluster_name, mdss)
268
269        if passwd:
270            self._execute('pstorage', '-c', cluster_name, 'auth-node', '-P',
271                          process_input=passwd,
272                          root_helper=self._root_helper, run_as_root=True)
273
274        mnt_cmd = ['pstorage-mount', '-c', cluster_name]
275        if flags:
276            mnt_cmd.extend(flags)
277        mnt_cmd.extend([mount_path])
278
279        self._execute(*mnt_cmd, root_helper=self._root_helper,
280                      run_as_root=True, check_exit_code=0)
281