X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/2b27132d13fd72f29dbab2297f0d2dc1c110eed1..20a7cf92c668d8d75ca3d1d64f028973a71245d4:/services/fuse/arvados_fuse/unmount.py diff --git a/services/fuse/arvados_fuse/unmount.py b/services/fuse/arvados_fuse/unmount.py index db78ddc738..a72da3a8dc 100644 --- a/services/fuse/arvados_fuse/unmount.py +++ b/services/fuse/arvados_fuse/unmount.py @@ -1,3 +1,7 @@ +# Copyright (C) The Arvados Authors. All rights reserved. +# +# SPDX-License-Identifier: AGPL-3.0 + import collections import errno import os @@ -26,7 +30,55 @@ def mountinfo(): return mi -def unmount(path, timeout=10, recursive=False): +def paths_to_unmount(path, mnttype): + paths = [] + for m in mountinfo(): + if m.path == path or m.path.startswith(path+"/"): + paths.append(m.path) + if not (m.is_fuse and (mnttype is None or + mnttype == m.mnttype)): + raise Exception( + "cannot unmount {}: mount type is {}".format( + path, m.mnttype)) + return paths + + +def safer_realpath(path, loop=True): + """Similar to os.path.realpath(), but avoids calling lstat(). + + Leaves some symlinks unresolved.""" + if path == '/': + return path, True + elif not path.startswith('/'): + path = os.path.abspath(path) + while True: + path = path.rstrip('/') + dirname, basename = os.path.split(path) + try: + path, resolved = safer_realpath(os.path.join(dirname, os.readlink(path)), loop=False) + except OSError as e: + # Path is not a symlink (EINVAL), or is unreadable, or + # doesn't exist. If the error was EINVAL and dirname can + # be resolved, we will have eliminated all symlinks and it + # will be safe to call normpath(). + dirname, resolved = safer_realpath(dirname, loop=loop) + path = os.path.join(dirname, basename) + if resolved and e.errno == errno.EINVAL: + return os.path.normpath(path), True + else: + return path, False + except RuntimeError: + if not loop: + # Unwind to the point where we first started following + # symlinks. + raise + # Resolving the whole path landed in a symlink cycle, but + # we might still be able to resolve dirname. + dirname, _ = safer_realpath(dirname, loop=loop) + return os.path.join(dirname, basename), False + + +def unmount(path, subtype=None, timeout=10, recursive=False): """Unmount the fuse mount at path. Unmounting is done by writing 1 to the "abort" control file in @@ -41,17 +93,23 @@ def unmount(path, timeout=10, recursive=False): fuse mount at all. Raises an exception if it cannot be unmounted. """ - path = os.path.realpath(path) + path, _ = safer_realpath(path) + + if subtype is None: + mnttype = None + elif subtype == '': + mnttype = 'fuse' + else: + mnttype = 'fuse.' + subtype if recursive: - paths = [] - for m in mountinfo(): - if m.path == path or m.path.startswith(path+"/"): - paths.append(m.path) - if not m.is_fuse: - raise Exception( - "cannot unmount {}: non-fuse mountpoint {}".format( - path, m)) + paths = paths_to_unmount(path, mnttype) + if not paths: + # We might not have found any mounts merely because path + # contains symlinks, so we should resolve them and try + # again. We didn't do this from the outset because + # realpath() can hang (see explanation below). + paths = paths_to_unmount(os.path.realpath(path), mnttype) for path in sorted(paths, key=len, reverse=True): unmount(path, timeout=timeout, recursive=False) return len(paths) > 0 @@ -66,15 +124,34 @@ def unmount(path, timeout=10, recursive=False): while True: mounted = False for m in mountinfo(): - if m.is_fuse: + if m.is_fuse and (mnttype is None or mnttype == m.mnttype): try: - if os.path.realpath(m.path) == path: + if m.path == path: was_mounted = True mounted = True break except OSError: continue - if not mounted: + if not was_mounted and path != os.path.realpath(path): + # If the specified path contains symlinks, it won't appear + # verbatim in mountinfo. + # + # It might seem like we should have called realpath() from + # the outset. But we can't: realpath() hangs (in lstat()) + # if we call it on an unresponsive mount point, and this + # is an important and common scenario. + # + # By waiting until now to try realpath(), we avoid this + # problem in the most common cases, which are: (1) the + # specified path has no symlinks and is a mount point, in + # which case was_mounted==True and we can proceed without + # calling realpath(); and (2) the specified path is not a + # mount point (e.g., it was already unmounted by someone + # else, or it's a typo), and realpath() can determine that + # without hitting any other unresponsive mounts. + path = os.path.realpath(path) + continue + elif not mounted: return was_mounted if attempted: @@ -89,7 +166,7 @@ def unmount(path, timeout=10, recursive=False): with open('/sys/fs/fuse/connections/{}/abort'.format(m.minor), 'w') as f: f.write("1") - except OSError as e: + except IOError as e: if e.errno != errno.ENOENT: raise