1# This Source Code Form is subject to the terms of the Mozilla Public
2# License, v. 2.0. If a copy of the MPL was not distributed with this
3# file, # You can obtain one at http://mozilla.org/MPL/2.0/.
4
5from __future__ import absolute_import, print_function
6
7import os
8import subprocess
9import six
10import sys
11
12import psutil
13
14from distutils.util import strtobool
15from distutils.version import LooseVersion
16
17import mozpack.path as mozpath
18
19# Minimum recommended logical processors in system.
20PROCESSORS_THRESHOLD = 4
21
22# Minimum recommended total system memory, in gigabytes.
23MEMORY_THRESHOLD = 7.4
24
25# Minimum recommended free space on each disk, in gigabytes.
26FREESPACE_THRESHOLD = 10
27
28# Latest MozillaBuild version
29LATEST_MOZILLABUILD_VERSION = '1.11.0'
30
31DISABLE_LASTACCESS_WIN = '''
32Disable the last access time feature?
33This improves the speed of file and
34directory access by deferring Last Access Time modification on disk by up to an
35hour. Backup programs that rely on this feature may be affected.
36https://technet.microsoft.com/en-us/library/cc785435.aspx
37'''
38
39
40class Doctor(object):
41    def __init__(self, srcdir, objdir, fix):
42        self.srcdir = mozpath.normpath(srcdir)
43        self.objdir = mozpath.normpath(objdir)
44        self.srcdir_mount = self.getmount(self.srcdir)
45        self.objdir_mount = self.getmount(self.objdir)
46        self.path_mounts = [
47            ('srcdir', self.srcdir, self.srcdir_mount),
48            ('objdir', self.objdir, self.objdir_mount)
49        ]
50        self.fix = fix
51        self.results = []
52
53    def check_all(self):
54        checks = [
55            'cpu',
56            'memory',
57            'storage_freespace',
58            'fs_lastaccess',
59            'mozillabuild'
60        ]
61        for check in checks:
62            self.report(getattr(self, check))
63        good = True
64        fixable = False
65        denied = False
66        for result in self.results:
67            if result.get('status') != 'GOOD':
68                good = False
69            if result.get('fixable', False):
70                fixable = True
71            if result.get('denied', False):
72                denied = True
73        if denied:
74            print('run "mach doctor --fix" AS ADMIN to re-attempt fixing your system')
75        elif False and fixable:  # elif fixable:  # 'and fixable' avoids flake8 error
76            print('run "mach doctor --fix" as admin to attempt fixing your system')
77        return int(not good)
78
79    def getmount(self, path):
80        while path != '/' and not os.path.ismount(path):
81            path = mozpath.abspath(mozpath.join(path, os.pardir))
82        return path
83
84    def prompt_bool(self, prompt, limit=5):
85        ''' Prompts the user with prompt and requires a boolean value. '''
86        valid = False
87        while not valid and limit > 0:
88            try:
89                choice = strtobool(six.moves.input(prompt + '[Y/N]\n'))
90                valid = True
91            except ValueError:
92                print("ERROR! Please enter a valid option!")
93                limit -= 1
94
95        if limit > 0:
96            return choice
97        else:
98            raise Exception("Error! Reached max attempts of entering option.")
99
100    def report(self, results):
101        # Handle single dict result or list of results.
102        if isinstance(results, dict):
103            results = [results]
104        for result in results:
105            status = result.get('status', 'UNSURE')
106            if status == 'SKIPPED':
107                continue
108            self.results.append(result)
109            print(
110                '{}...\t{}\n'.format(
111                    result.get('desc', ''),
112                    status
113                ).expandtabs(40)
114            )
115
116    @property
117    def platform(self):
118        platform = getattr(self, '_platform', None)
119        if not platform:
120            platform = sys.platform
121            while platform[-1].isdigit():
122                platform = platform[:-1]
123            setattr(self, '_platform', platform)
124        return platform
125
126    @property
127    def cpu(self):
128        cpu_count = psutil.cpu_count()
129        if cpu_count < PROCESSORS_THRESHOLD:
130            status = 'BAD'
131            desc = '%d logical processors detected, <%d' % (
132                cpu_count, PROCESSORS_THRESHOLD
133            )
134        else:
135            status = 'GOOD'
136            desc = '%d logical processors detected, >=%d' % (
137                cpu_count, PROCESSORS_THRESHOLD
138            )
139        return {'status': status, 'desc': desc}
140
141    @property
142    def memory(self):
143        memory = psutil.virtual_memory().total
144        # Convert to gigabytes.
145        memory_GB = memory / 1024**3.0
146        if memory_GB < MEMORY_THRESHOLD:
147            status = 'BAD'
148            desc = '%.1fGB of physical memory, <%.1fGB' % (
149                memory_GB, MEMORY_THRESHOLD
150            )
151        else:
152            status = 'GOOD'
153            desc = '%.1fGB of physical memory, >%.1fGB' % (
154                memory_GB, MEMORY_THRESHOLD
155            )
156        return {'status': status, 'desc': desc}
157
158    @property
159    def storage_freespace(self):
160        results = []
161        desc = ''
162        mountpoint_line = self.srcdir_mount != self.objdir_mount
163        for (purpose, path, mount) in self.path_mounts:
164            desc += '%s = %s\n' % (purpose, path)
165            if not mountpoint_line:
166                mountpoint_line = True
167                continue
168            try:
169                usage = psutil.disk_usage(mount)
170                freespace, size = usage.free, usage.total
171                freespace_GB = freespace / 1024**3
172                size_GB = size / 1024**3
173                if freespace_GB < FREESPACE_THRESHOLD:
174                    status = 'BAD'
175                    desc += 'mountpoint = %s\n%dGB of %dGB free, <%dGB' % (
176                        mount, freespace_GB, size_GB, FREESPACE_THRESHOLD
177                    )
178                else:
179                    status = 'GOOD'
180                    desc += 'mountpoint = %s\n%dGB of %dGB free, >=%dGB' % (
181                        mount, freespace_GB, size_GB, FREESPACE_THRESHOLD
182                    )
183            except OSError:
184                status = 'UNSURE'
185                desc += 'path invalid'
186            results.append({'status': status, 'desc': desc})
187        return results
188
189    @property
190    def fs_lastaccess(self):
191        results = []
192        if self.platform == 'win':
193            fixable = False
194            denied = False
195            # See 'fsutil behavior':
196            # https://technet.microsoft.com/en-us/library/cc785435.aspx
197            try:
198                command = 'fsutil behavior query disablelastaccess'.split(' ')
199                fsutil_output = subprocess.check_output(command)
200                disablelastaccess = int(fsutil_output.partition('=')[2][1])
201            except subprocess.CalledProcessError:
202                disablelastaccess = -1
203                status = 'UNSURE'
204                desc = 'unable to check lastaccess behavior'
205            if disablelastaccess == 1:
206                status = 'GOOD'
207                desc = 'lastaccess disabled systemwide'
208            elif disablelastaccess == 0:
209                if False:  # if self.fix:
210                    choice = self.prompt_bool(DISABLE_LASTACCESS_WIN)
211                    if not choice:
212                        return {'status': 'BAD, NOT FIXED',
213                                'desc': 'lastaccess enabled systemwide'}
214                    try:
215                        command = 'fsutil behavior set disablelastaccess 1'.split(' ')
216                        fsutil_output = subprocess.check_output(command)
217                        status = 'GOOD, FIXED'
218                        desc = 'lastaccess disabled systemwide'
219                    except subprocess.CalledProcessError as e:
220                        desc = 'lastaccess enabled systemwide'
221                        if e.output.find('denied') != -1:
222                            status = 'BAD, FIX DENIED'
223                            denied = True
224                        else:
225                            status = 'BAD, NOT FIXED'
226                else:
227                    status = 'BAD, FIXABLE'
228                    desc = 'lastaccess enabled'
229                    fixable = True
230            results.append({'status': status, 'desc': desc, 'fixable': fixable,
231                            'denied': denied})
232        elif self.platform in ['freebsd', 'linux', 'openbsd']:
233            common_mountpoint = self.srcdir_mount == self.objdir_mount
234            for (purpose, path, mount) in self.path_mounts:
235                results.append(self.check_mount_lastaccess(mount))
236                if common_mountpoint:
237                    break
238        else:
239            results.append({'status': 'SKIPPED'})
240        return results
241
242    def check_mount_lastaccess(self, mount):
243        partitions = psutil.disk_partitions(all=True)
244        atime_opts = {'atime', 'noatime', 'relatime', 'norelatime'}
245        option = ''
246        fstype = ''
247        for partition in partitions:
248            if partition.mountpoint == mount:
249                mount_opts = set(partition.opts.split(','))
250                intersection = list(atime_opts & mount_opts)
251                fstype = partition.fstype
252                if len(intersection) == 1:
253                    option = intersection[0]
254                break
255
256        if fstype == 'tmpfs':
257            status = 'GOOD'
258            desc = '%s is a tmpfs so noatime/reltime is not needed' % (
259                mount
260            )
261        elif not option:
262            status = 'BAD'
263            if self.platform == 'linux':
264                option = 'noatime/relatime'
265            else:
266                option = 'noatime'
267            desc = '%s has no explicit %s mount option' % (
268                mount, option
269            )
270        elif option == 'atime' or option == 'norelatime':
271            status = 'BAD'
272            desc = '%s has %s mount option' % (
273                mount, option
274            )
275        elif option == 'noatime' or option == 'relatime':
276            status = 'GOOD'
277            desc = '%s has %s mount option' % (
278                mount, option
279            )
280        return {'status': status, 'desc': desc}
281
282    @property
283    def mozillabuild(self):
284        if self.platform != 'win':
285            return {'status': 'SKIPPED'}
286        MOZILLABUILD = mozpath.normpath(os.environ.get('MOZILLABUILD', ''))
287        if not MOZILLABUILD or not os.path.exists(MOZILLABUILD):
288            return {'desc': 'not running under MozillaBuild'}
289        try:
290            with open(mozpath.join(MOZILLABUILD, 'VERSION'), 'r') as fh:
291                version = fh.readline()
292            if not version:
293                raise ValueError()
294            if LooseVersion(version) < LooseVersion(LATEST_MOZILLABUILD_VERSION):
295                status = 'BAD'
296                desc = 'MozillaBuild %s in use, <%s' % (
297                    version, LATEST_MOZILLABUILD_VERSION
298                )
299            else:
300                status = 'GOOD'
301                desc = 'MozillaBuild %s in use' % version
302        except (IOError, ValueError):
303            status = 'UNSURE'
304            desc = 'MozillaBuild version not found'
305        return {'status': status, 'desc': desc}
306