summaryrefslogtreecommitdiffstats
path: root/python/mozbuild/mozbuild/doctor.py
diff options
context:
space:
mode:
Diffstat (limited to 'python/mozbuild/mozbuild/doctor.py')
-rw-r--r--python/mozbuild/mozbuild/doctor.py605
1 files changed, 605 insertions, 0 deletions
diff --git a/python/mozbuild/mozbuild/doctor.py b/python/mozbuild/mozbuild/doctor.py
new file mode 100644
index 0000000000..649b50200d
--- /dev/null
+++ b/python/mozbuild/mozbuild/doctor.py
@@ -0,0 +1,605 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, # You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import enum
+import locale
+import os
+import socket
+import subprocess
+import sys
+from pathlib import Path
+from typing import Callable, List, Optional, Union
+
+import attr
+import mozpack.path as mozpath
+import mozversioncontrol
+import psutil
+import requests
+from packaging.version import Version
+
+# Minimum recommended logical processors in system.
+PROCESSORS_THRESHOLD = 4
+
+# Minimum recommended total system memory, in gigabytes.
+MEMORY_THRESHOLD = 7.4
+
+# Minimum recommended free space on each disk, in gigabytes.
+FREESPACE_THRESHOLD = 10
+
+# Latest MozillaBuild version.
+LATEST_MOZILLABUILD_VERSION = Version("4.0")
+
+DISABLE_LASTACCESS_WIN = """
+Disable the last access time feature?
+This improves the speed of file and
+directory access by deferring Last Access Time modification on disk by up to an
+hour. Backup programs that rely on this feature may be affected.
+https://technet.microsoft.com/en-us/library/cc785435.aspx
+"""
+
+COMPILED_LANGUAGE_FILE_EXTENSIONS = [
+ ".cc",
+ ".cxx",
+ ".c",
+ ".cpp",
+ ".h",
+ ".hpp",
+ ".rs",
+ ".rlib",
+ ".mk",
+]
+
+
+def get_mount_point(path: str) -> str:
+ """Return the mount point for a given path."""
+ while path != "/" and not os.path.ismount(path):
+ path = mozpath.abspath(mozpath.join(path, os.pardir))
+ return path
+
+
+class CheckStatus(enum.Enum):
+ # Check is okay.
+ OK = enum.auto()
+ # We found an issue.
+ WARNING = enum.auto()
+ # We found an issue that will break build/configure/etc.
+ FATAL = enum.auto()
+ # The check was skipped.
+ SKIPPED = enum.auto()
+
+
+@attr.s
+class DoctorCheck:
+ # Name of the check.
+ name = attr.ib()
+ # Lines to display on screen.
+ display_text = attr.ib()
+ # `CheckStatus` for this given check.
+ status = attr.ib()
+ # Function to be called to fix the issues, if applicable.
+ fix = attr.ib(default=None)
+
+
+CHECKS = {}
+
+
+def check(func: Callable):
+ """Decorator that registers a function as a doctor check.
+
+ The function should return a `DoctorCheck` or be an iterator of
+ checks.
+ """
+ CHECKS[func.__name__] = func
+
+
+@check
+def dns(**kwargs) -> DoctorCheck:
+ """Check DNS is queryable."""
+ try:
+ socket.getaddrinfo("mozilla.org", 80)
+ return DoctorCheck(
+ name="dns",
+ status=CheckStatus.OK,
+ display_text=["DNS query for mozilla.org completed successfully."],
+ )
+
+ except socket.gaierror:
+ return DoctorCheck(
+ name="dns",
+ status=CheckStatus.FATAL,
+ display_text=["Could not query DNS for mozilla.org."],
+ )
+
+
+@check
+def internet(**kwargs) -> DoctorCheck:
+ """Check the internet is reachable via HTTPS."""
+ try:
+ resp = requests.get("https://mozilla.org")
+ resp.raise_for_status()
+
+ return DoctorCheck(
+ name="internet",
+ status=CheckStatus.OK,
+ display_text=["Internet is reachable."],
+ )
+
+ except Exception:
+ return DoctorCheck(
+ name="internet",
+ status=CheckStatus.FATAL,
+ display_text=["Could not reach a known website via HTTPS."],
+ )
+
+
+@check
+def ssh(**kwargs) -> DoctorCheck:
+ """Check the status of `ssh hg.mozilla.org` for common errors."""
+ try:
+ # We expect this command to return exit code 1 even when we hit
+ # the successful code path, since we don't specify a `pash` command.
+ proc = subprocess.run(
+ ["ssh", "hg.mozilla.org"],
+ encoding="utf-8",
+ stderr=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ )
+
+ # Command output from a successful `pash` run.
+ if "has privileges to access Mercurial over" in proc.stdout:
+ return DoctorCheck(
+ name="ssh",
+ status=CheckStatus.OK,
+ display_text=["SSH is properly configured for access to hg."],
+ )
+
+ if "Permission denied" in proc.stdout:
+ # Parse proc.stdout for username, which looks like:
+ # `<username>@hg.mozilla.org: Permission denied (reason)`
+ login_string = proc.stdout.split()[0]
+ username, _host = login_string.split("@hg.mozilla.org")
+
+ # `<username>` should be an email.
+ if "@" not in username:
+ return DoctorCheck(
+ name="ssh",
+ status=CheckStatus.FATAL,
+ display_text=[
+ "SSH username `{}` is not an email address.".format(username),
+ "hg.mozilla.org logins should be in the form `user@domain.com`.",
+ ],
+ )
+
+ return DoctorCheck(
+ name="ssh",
+ status=CheckStatus.WARNING,
+ display_text=[
+ "SSH username `{}` does not have permission to push to "
+ "hg.mozilla.org.".format(username)
+ ],
+ )
+
+ if "Mercurial access is currently disabled on your account" in proc.stdout:
+ return DoctorCheck(
+ name="ssh",
+ status=CheckStatus.FATAL,
+ display_text=[
+ "You previously had push access to hgmo, but due to inactivity",
+ "your access was revoked. Please file a bug in Bugzilla under",
+ "`Infrastructure & Operations :: Infrastructure: LDAP` to request",
+ "access.",
+ ],
+ )
+
+ return DoctorCheck(
+ name="ssh",
+ status=CheckStatus.WARNING,
+ display_text=[
+ "Unexpected output from `ssh hg.mozilla.org`:",
+ proc.stdout,
+ ],
+ )
+
+ except subprocess.CalledProcessError:
+ return DoctorCheck(
+ name="ssh",
+ status=CheckStatus.WARNING,
+ display_text=["Could not run `ssh hg.mozilla.org`."],
+ )
+
+
+@check
+def cpu(**kwargs) -> DoctorCheck:
+ """Check the host machine has the recommended processing power to develop Firefox."""
+ cpu_count = psutil.cpu_count()
+ if cpu_count < PROCESSORS_THRESHOLD:
+ status = CheckStatus.WARNING
+ desc = "%d logical processors detected, <%d" % (cpu_count, PROCESSORS_THRESHOLD)
+ else:
+ status = CheckStatus.OK
+ desc = "%d logical processors detected, >=%d" % (
+ cpu_count,
+ PROCESSORS_THRESHOLD,
+ )
+
+ return DoctorCheck(name="cpu", display_text=[desc], status=status)
+
+
+@check
+def memory(**kwargs) -> DoctorCheck:
+ """Check the host machine has the recommended memory to develop Firefox."""
+ memory = psutil.virtual_memory().total
+ # Convert to gigabytes.
+ memory_GB = memory / 1024 ** 3.0
+ if memory_GB < MEMORY_THRESHOLD:
+ status = CheckStatus.WARNING
+ desc = "%.1fGB of physical memory, <%.1fGB" % (memory_GB, MEMORY_THRESHOLD)
+ else:
+ status = CheckStatus.OK
+ desc = "%.1fGB of physical memory, >%.1fGB" % (memory_GB, MEMORY_THRESHOLD)
+
+ return DoctorCheck(name="memory", display_text=[desc], status=status)
+
+
+@check
+def storage_freespace(topsrcdir: str, topobjdir: str, **kwargs) -> List[DoctorCheck]:
+ """Check the host machine has the recommended disk space to develop Firefox."""
+ topsrcdir_mount = get_mount_point(topsrcdir)
+ topobjdir_mount = get_mount_point(topobjdir)
+
+ mounts = [
+ ("topsrcdir", topsrcdir, topsrcdir_mount),
+ ("topobjdir", topobjdir, topobjdir_mount),
+ ]
+
+ mountpoint_line = topsrcdir_mount != topobjdir_mount
+ checks = []
+
+ for purpose, path, mount in mounts:
+ if not mountpoint_line:
+ mountpoint_line = True
+ continue
+
+ desc = ["%s = %s" % (purpose, path)]
+
+ try:
+ usage = psutil.disk_usage(mount)
+ freespace, size = usage.free, usage.total
+ freespace_GB = freespace / 1024 ** 3
+ size_GB = size / 1024 ** 3
+ if freespace_GB < FREESPACE_THRESHOLD:
+ status = CheckStatus.WARNING
+ desc.append(
+ "mountpoint = %s\n%dGB of %dGB free, <%dGB"
+ % (mount, freespace_GB, size_GB, FREESPACE_THRESHOLD)
+ )
+ else:
+ status = CheckStatus.OK
+ desc.append(
+ "mountpoint = %s\n%dGB of %dGB free, >=%dGB"
+ % (mount, freespace_GB, size_GB, FREESPACE_THRESHOLD)
+ )
+
+ except OSError:
+ status = CheckStatus.FATAL
+ desc.append("path invalid")
+
+ checks.append(
+ DoctorCheck(name="%s mount check" % mount, status=status, display_text=desc)
+ )
+
+ return checks
+
+
+def fix_lastaccess_win():
+ """Run `fsutil` to fix lastaccess behaviour."""
+ try:
+ print("Disabling filesystem lastaccess")
+
+ command = ["fsutil", "behavior", "set", "disablelastaccess", "1"]
+ subprocess.check_output(command)
+
+ print("Filesystem lastaccess disabled.")
+
+ except subprocess.CalledProcessError:
+ print("Could not disable filesystem lastaccess.")
+
+
+@check
+def fs_lastaccess(
+ topsrcdir: str, topobjdir: str, **kwargs
+) -> Union[DoctorCheck, List[DoctorCheck]]:
+ """Check for the `lastaccess` behaviour on the filsystem, which can slow
+ down filesystem operations."""
+ if sys.platform.startswith("win"):
+ # See 'fsutil behavior':
+ # https://technet.microsoft.com/en-us/library/cc785435.aspx
+ try:
+ command = ["fsutil", "behavior", "query", "disablelastaccess"]
+ fsutil_output = subprocess.check_output(command, encoding="utf-8")
+ disablelastaccess = int(fsutil_output.partition("=")[2][1])
+ except subprocess.CalledProcessError:
+ return DoctorCheck(
+ name="lastaccess",
+ status=CheckStatus.WARNING,
+ display_text=["unable to check lastaccess behavior"],
+ )
+
+ if disablelastaccess in {1, 3}:
+ return DoctorCheck(
+ name="lastaccess",
+ status=CheckStatus.OK,
+ display_text=["lastaccess disabled systemwide"],
+ )
+ elif disablelastaccess in {0, 2}:
+ return DoctorCheck(
+ name="lastaccess",
+ status=CheckStatus.WARNING,
+ display_text=["lastaccess enabled"],
+ fix=fix_lastaccess_win,
+ )
+
+ # `disablelastaccess` should be a value between 0-3.
+ return DoctorCheck(
+ name="lastaccess",
+ status=CheckStatus.WARNING,
+ display_text=["Could not parse `fsutil` for lastaccess behavior."],
+ )
+
+ elif any(
+ sys.platform.startswith(prefix) for prefix in ["freebsd", "linux", "openbsd"]
+ ):
+ topsrcdir_mount = get_mount_point(topsrcdir)
+ topobjdir_mount = get_mount_point(topobjdir)
+ mounts = [
+ ("topsrcdir", topsrcdir, topsrcdir_mount),
+ ("topobjdir", topobjdir, topobjdir_mount),
+ ]
+
+ common_mountpoint = topsrcdir_mount == topobjdir_mount
+
+ mount_checks = []
+ for _purpose, _path, mount in mounts:
+ mount_checks.append(check_mount_lastaccess(mount))
+ if common_mountpoint:
+ break
+
+ return mount_checks
+
+ # Return "SKIPPED" if this test is not relevant.
+ return DoctorCheck(
+ name="lastaccess",
+ display_text=["lastaccess not relevant for this platform."],
+ status=CheckStatus.SKIPPED,
+ )
+
+
+def check_mount_lastaccess(mount: str) -> DoctorCheck:
+ """Check `lastaccess` behaviour for a Linux mount."""
+ partitions = psutil.disk_partitions(all=True)
+ atime_opts = {"atime", "noatime", "relatime", "norelatime"}
+ option = ""
+ fstype = ""
+ for partition in partitions:
+ if partition.mountpoint == mount:
+ mount_opts = set(partition.opts.split(","))
+ intersection = list(atime_opts & mount_opts)
+ fstype = partition.fstype
+ if len(intersection) == 1:
+ option = intersection[0]
+ break
+
+ if fstype == "tmpfs":
+ status = CheckStatus.OK
+ desc = "%s is a tmpfs so noatime/reltime is not needed" % (mount)
+ elif not option:
+ status = CheckStatus.WARNING
+ if sys.platform.startswith("linux"):
+ option = "noatime/relatime"
+ else:
+ option = "noatime"
+ desc = "%s has no explicit %s mount option" % (mount, option)
+ elif option == "atime" or option == "norelatime":
+ status = CheckStatus.WARNING
+ desc = "%s has %s mount option" % (mount, option)
+ elif option == "noatime" or option == "relatime":
+ status = CheckStatus.OK
+ desc = "%s has %s mount option" % (mount, option)
+
+ return DoctorCheck(
+ name="%s mount lastaccess" % mount, status=status, display_text=[desc]
+ )
+
+
+@check
+def mozillabuild(**kwargs) -> DoctorCheck:
+ """Check that MozillaBuild is the latest version."""
+ if not sys.platform.startswith("win"):
+ return DoctorCheck(
+ name="mozillabuild",
+ status=CheckStatus.SKIPPED,
+ display_text=["Non-Windows platform, MozillaBuild not relevant"],
+ )
+
+ MOZILLABUILD = mozpath.normpath(os.environ.get("MOZILLABUILD", ""))
+ if not MOZILLABUILD or not os.path.exists(MOZILLABUILD):
+ return DoctorCheck(
+ name="mozillabuild",
+ status=CheckStatus.WARNING,
+ display_text=["Not running under MozillaBuild."],
+ )
+
+ try:
+ with open(mozpath.join(MOZILLABUILD, "VERSION"), "r") as fh:
+ local_version = fh.readline()
+
+ if not local_version:
+ return DoctorCheck(
+ name="mozillabuild",
+ status=CheckStatus.WARNING,
+ display_text=["Could not get local MozillaBuild version."],
+ )
+
+ if Version(local_version) < LATEST_MOZILLABUILD_VERSION:
+ status = CheckStatus.WARNING
+ desc = "MozillaBuild %s in use, <%s" % (
+ local_version,
+ LATEST_MOZILLABUILD_VERSION,
+ )
+
+ else:
+ status = CheckStatus.OK
+ desc = "MozillaBuild %s in use" % local_version
+
+ except (IOError, ValueError):
+ status = CheckStatus.FATAL
+ desc = "MozillaBuild version not found"
+
+ return DoctorCheck(name="mozillabuild", status=status, display_text=[desc])
+
+
+@check
+def bad_locale_utf8(**kwargs) -> DoctorCheck:
+ """Check to detect the invalid locale `UTF-8` on pre-3.8 Python."""
+ if sys.version_info >= (3, 8):
+ return DoctorCheck(
+ name="utf8 locale",
+ status=CheckStatus.SKIPPED,
+ display_text=["Python version has fixed utf-8 locale bug."],
+ )
+
+ try:
+ # This line will attempt to get and parse the locale.
+ locale.getdefaultlocale()
+
+ return DoctorCheck(
+ name="utf8 locale",
+ status=CheckStatus.OK,
+ display_text=["Python's locale is set to a valid value."],
+ )
+ except ValueError:
+ return DoctorCheck(
+ name="utf8 locale",
+ status=CheckStatus.FATAL,
+ display_text=[
+ "Your Python is using an invalid value for its locale.",
+ "Either update Python to version 3.8+, or set the following variables in ",
+ "your environment:",
+ " export LC_ALL=en_US.UTF-8",
+ " export LANG=en_US.UTF-8",
+ ],
+ )
+
+
+@check
+def artifact_build(
+ topsrcdir: str, configure_args: Optional[List[str]], **kwargs
+) -> DoctorCheck:
+ """Check that if Artifact Builds are enabled, that no
+ source files that would not be compiled are changed"""
+
+ if configure_args is None or "--enable-artifact-builds" not in configure_args:
+ return DoctorCheck(
+ name="artifact_build",
+ status=CheckStatus.SKIPPED,
+ display_text=[
+ "Artifact Builds are not enabled. No need to proceed checking for changed files."
+ ],
+ )
+
+ repo = mozversioncontrol.get_repository_object(topsrcdir)
+ changed_files = [
+ Path(file)
+ for file in set(repo.get_outgoing_files()) | set(repo.get_changed_files())
+ ]
+
+ compiled_language_files_changed = ""
+ for file in changed_files:
+ if (
+ file.suffix in COMPILED_LANGUAGE_FILE_EXTENSIONS
+ or file.stem.lower() == "makefile"
+ and not file.suffix == ".py"
+ ):
+ compiled_language_files_changed += ' - "' + str(file) + '"\n'
+
+ if compiled_language_files_changed:
+ return DoctorCheck(
+ name="artifact_build",
+ status=CheckStatus.FATAL,
+ display_text=[
+ "Artifact Builds are enabled, but the following files from compiled languages "
+ f"have been modified: \n{compiled_language_files_changed}\nThese files will "
+ "not be compiled, and your changes will not be realized in the build output."
+ "\n\nIf you want these changes to be realized, you should re-run './mach "
+ 'boostrap` and select a build that does not state "Artifact Mode".'
+ "\nFor additional information on Artifact Builds see: "
+ "https://firefox-source-docs.mozilla.org/contributing/build/"
+ "artifact_builds.html"
+ ],
+ )
+
+ return DoctorCheck(
+ name="artifact_build",
+ status=CheckStatus.OK,
+ display_text=["No Artifact Build conflicts found."],
+ )
+
+
+def run_doctor(fix: bool = False, verbose: bool = False, **kwargs) -> int:
+ """Run the doctor checks.
+
+ If `fix` is `True`, run fixing functions for issues that can be resolved
+ automatically.
+
+ By default, only print output from checks that result in a warning or
+ fatal issue. `verbose` will cause all output to be printed to the screen.
+ """
+ issues_found = False
+
+ fixes = []
+ for _name, check_func in CHECKS.items():
+ results = check_func(**kwargs)
+
+ if isinstance(results, DoctorCheck):
+ results = [results]
+
+ for result in results:
+ if result.status == CheckStatus.SKIPPED and not verbose:
+ continue
+
+ if result.status != CheckStatus.OK:
+ # If we ever have a non-OK status, we shouldn't print
+ # the "No issues detected" line.
+ issues_found = True
+
+ if result.status != CheckStatus.OK or verbose:
+ print("\n".join(result.display_text))
+
+ if result.fix:
+ fixes.append(result.fix)
+
+ if not issues_found:
+ print("No issues detected.")
+ return 0
+
+ # If we can fix something but the user didn't ask us to, advise
+ # them to run with `--fix`.
+ if not fix:
+ if fixes:
+ print(
+ "Some of the issues found can be fixed; run "
+ "`./mach doctor --fix` to fix them."
+ )
+ return 1
+
+ # Attempt to run the fix functions.
+ fixer_fail = 0
+ for fixer in fixes:
+ try:
+ fixer()
+ except Exception:
+ fixer_fail = 1
+ pass
+
+ return fixer_fail