16723: Don't lock after requeue until old crunch-run exits.
[arvados.git] / services / fuse / arvados_fuse / unmount.py
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 import collections
6 import errno
7 import os
8 import subprocess
9 import time
10
11
12 MountInfo = collections.namedtuple(
13     'MountInfo', ['is_fuse', 'major', 'minor', 'mnttype', 'path'])
14
15
16 def mountinfo():
17     mi = []
18     with open('/proc/self/mountinfo') as f:
19         for m in f.readlines():
20             mntid, pmntid, dev, root, path, extra = m.split(" ", 5)
21             mnttype = extra.split(" - ")[1].split(" ", 1)[0]
22             major, minor = dev.split(":")
23             mi.append(MountInfo(
24                 is_fuse=(mnttype == "fuse" or mnttype.startswith("fuse.")),
25                 major=major,
26                 minor=minor,
27                 mnttype=mnttype,
28                 path=path,
29             ))
30     return mi
31
32
33 def paths_to_unmount(path, mnttype):
34     paths = []
35     for m in mountinfo():
36         if m.path == path or m.path.startswith(path+"/"):
37             paths.append(m.path)
38             if not (m.is_fuse and (mnttype is None or
39                                    mnttype == m.mnttype)):
40                 raise Exception(
41                     "cannot unmount {}: mount type is {}".format(
42                         path, m.mnttype))
43     return paths
44
45
46 def safer_realpath(path, loop=True):
47     """Similar to os.path.realpath(), but avoids calling lstat().
48
49     Leaves some symlinks unresolved."""
50     if path == '/':
51         return path, True
52     elif not path.startswith('/'):
53         path = os.path.abspath(path)
54     while True:
55         path = path.rstrip('/')
56         dirname, basename = os.path.split(path)
57         try:
58             path, resolved = safer_realpath(os.path.join(dirname, os.readlink(path)), loop=False)
59         except OSError as e:
60             # Path is not a symlink (EINVAL), or is unreadable, or
61             # doesn't exist. If the error was EINVAL and dirname can
62             # be resolved, we will have eliminated all symlinks and it
63             # will be safe to call normpath().
64             dirname, resolved = safer_realpath(dirname, loop=loop)
65             path = os.path.join(dirname, basename)
66             if resolved and e.errno == errno.EINVAL:
67                 return os.path.normpath(path), True
68             else:
69                 return path, False
70         except RuntimeError:
71             if not loop:
72                 # Unwind to the point where we first started following
73                 # symlinks.
74                 raise
75             # Resolving the whole path landed in a symlink cycle, but
76             # we might still be able to resolve dirname.
77             dirname, _ = safer_realpath(dirname, loop=loop)
78             return os.path.join(dirname, basename), False
79
80
81 def unmount(path, subtype=None, timeout=10, recursive=False):
82     """Unmount the fuse mount at path.
83
84     Unmounting is done by writing 1 to the "abort" control file in
85     sysfs to kill the fuse driver process, then executing "fusermount
86     -u -z" to detach the mount point, and repeating these steps until
87     the mount is no longer listed in /proc/self/mountinfo.
88
89     This procedure should enable a non-root user to reliably unmount
90     their own fuse filesystem without risk of deadlock.
91
92     Returns True if unmounting was successful, False if it wasn't a
93     fuse mount at all. Raises an exception if it cannot be unmounted.
94     """
95
96     path, _ = safer_realpath(path)
97
98     if subtype is None:
99         mnttype = None
100     elif subtype == '':
101         mnttype = 'fuse'
102     else:
103         mnttype = 'fuse.' + subtype
104
105     if recursive:
106         paths = paths_to_unmount(path, mnttype)
107         if not paths:
108             # We might not have found any mounts merely because path
109             # contains symlinks, so we should resolve them and try
110             # again. We didn't do this from the outset because
111             # realpath() can hang (see explanation below).
112             paths = paths_to_unmount(os.path.realpath(path), mnttype)
113         for path in sorted(paths, key=len, reverse=True):
114             unmount(path, timeout=timeout, recursive=False)
115         return len(paths) > 0
116
117     was_mounted = False
118     attempted = False
119     fusermount_output = b''
120     if timeout is None:
121         deadline = None
122     else:
123         deadline = time.time() + timeout
124
125     while True:
126         mounted = False
127         for m in mountinfo():
128             if m.is_fuse and (mnttype is None or mnttype == m.mnttype):
129                 try:
130                     if m.path == path:
131                         was_mounted = True
132                         mounted = True
133                         break
134                 except OSError:
135                     continue
136         if not was_mounted and path != os.path.realpath(path):
137             # If the specified path contains symlinks, it won't appear
138             # verbatim in mountinfo.
139             #
140             # It might seem like we should have called realpath() from
141             # the outset. But we can't: realpath() hangs (in lstat())
142             # if we call it on an unresponsive mount point, and this
143             # is an important and common scenario.
144             #
145             # By waiting until now to try realpath(), we avoid this
146             # problem in the most common cases, which are: (1) the
147             # specified path has no symlinks and is a mount point, in
148             # which case was_mounted==True and we can proceed without
149             # calling realpath(); and (2) the specified path is not a
150             # mount point (e.g., it was already unmounted by someone
151             # else, or it's a typo), and realpath() can determine that
152             # without hitting any other unresponsive mounts.
153             path = os.path.realpath(path)
154             continue
155         elif not mounted:
156             return was_mounted
157
158         if attempted:
159             # Report buffered stderr from previous call to fusermount,
160             # now that we know it didn't succeed.
161             sys.stderr.write(fusermount_output)
162
163             delay = 1
164             if deadline:
165                 delay = min(delay, deadline - time.time())
166                 if delay <= 0:
167                     raise Exception("timed out")
168             time.sleep(delay)
169
170         try:
171             with open('/sys/fs/fuse/connections/{}/abort'.format(m.minor),
172                       'w') as f:
173                 f.write("1")
174         except IOError as e:
175             if e.errno != errno.ENOENT:
176                 raise
177
178         attempted = True
179         try:
180             subprocess.check_output(
181                 ["fusermount", "-u", "-z", path],
182                 stderr=subprocess.STDOUT)
183         except subprocess.CalledProcessError as e:
184             fusermount_output = e.output
185         else:
186             fusermount_output = b''