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