mach_initialize.py (20419B)
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 5 import math 6 import os 7 import shutil 8 import sys 9 from importlib.abc import MetaPathFinder 10 from pathlib import Path 11 12 STATE_DIR_FIRST_RUN = """ 13 Mach and the build system store shared state in a common directory 14 on the filesystem. The following directory will be created: 15 16 {} 17 18 If you would like to use a different directory, rename or move it to your 19 desired location, and set the MOZBUILD_STATE_PATH environment variable 20 accordingly. 21 """.strip() 22 23 24 CATEGORIES = { 25 "build": { 26 "short": "Build Commands", 27 "long": "Interact with the build system", 28 "priority": 80, 29 }, 30 "post-build": { 31 "short": "Post-build Commands", 32 "long": "Common actions performed after completing a build.", 33 "priority": 70, 34 }, 35 "testing": { 36 "short": "Testing", 37 "long": "Run tests.", 38 "priority": 60, 39 }, 40 "ci": { 41 "short": "CI", 42 "long": "Taskcluster commands", 43 "priority": 59, 44 }, 45 "devenv": { 46 "short": "Development Environment", 47 "long": "Set up and configure your development environment.", 48 "priority": 50, 49 }, 50 "build-dev": { 51 "short": "Low-level Build System Interaction", 52 "long": "Interact with specific parts of the build system.", 53 "priority": 20, 54 }, 55 "misc": { 56 "short": "Potpourri", 57 "long": "Potent potables and assorted snacks.", 58 "priority": 10, 59 }, 60 "release": { 61 "short": "Release automation", 62 "long": "Commands for used in release automation.", 63 "priority": 5, 64 }, 65 "disabled": { 66 "short": "Disabled", 67 "long": "The disabled commands are hidden by default. Use -v to display them. " 68 "These commands are unavailable for your current context, " 69 'run "mach <command>" to see why.', 70 "priority": 0, 71 }, 72 } 73 74 75 def _activate_python_environment(topsrcdir, get_state_dir, quiet): 76 from mach.site import MachSiteManager 77 78 mach_environment = MachSiteManager.from_environment( 79 topsrcdir, get_state_dir, quiet=quiet 80 ) 81 mach_environment.activate() 82 83 84 def _maybe_activate_mozillabuild_environment(): 85 if sys.platform != "win32": 86 return 87 88 mozillabuild = Path(os.environ.get("MOZILLABUILD", r"C:\mozilla-build")) 89 os.environ.setdefault("MOZILLABUILD", str(mozillabuild)) 90 assert mozillabuild.exists(), ( 91 f'MozillaBuild was not found at "{mozillabuild}".\n' 92 "If it's installed in a different location, please " 93 'set the "MOZILLABUILD" environment variable ' 94 "accordingly." 95 ) 96 97 use_msys2 = (mozillabuild / "msys2").exists() 98 if use_msys2: 99 mozillabuild_msys_tools_path = mozillabuild / "msys2" / "usr" / "bin" 100 else: 101 mozillabuild_msys_tools_path = mozillabuild / "msys" / "bin" 102 103 paths_to_add = [mozillabuild_msys_tools_path, mozillabuild / "bin"] 104 existing_paths = [Path(p) for p in os.environ.get("PATH", "").split(os.pathsep)] 105 for new_path in paths_to_add: 106 if new_path not in existing_paths: 107 os.environ["PATH"] += f"{os.pathsep}{new_path}" 108 109 110 def check_for_spaces(topsrcdir): 111 if " " in topsrcdir: 112 raise Exception( 113 f"Your checkout at path '{topsrcdir}' contains a space, which " 114 f"is not supported. Please move it to somewhere that does not " 115 f"have a space in the path before rerunning mach." 116 ) 117 118 mozillabuild_dir = os.environ.get("MOZILLABUILD", "") 119 if sys.platform == "win32" and " " in mozillabuild_dir: 120 raise Exception( 121 f"Your installation of MozillaBuild appears to be installed on a path that " 122 f"contains a space ('{mozillabuild_dir}') which is not supported. Please " 123 f"reinstall MozillaBuild on a path without a space and restart your shell" 124 f"from the new installation." 125 ) 126 127 128 def initialize(topsrcdir, args=()): 129 # This directory was deleted in bug 1666345, but there may be some ignored 130 # files here. We can safely just delete it for the user so they don't have 131 # to clean the repo themselves. 132 deleted_dir = os.path.join(topsrcdir, "third_party", "python", "psutil") 133 if os.path.exists(deleted_dir): 134 shutil.rmtree(deleted_dir, ignore_errors=True) 135 136 # We need the "mach" module to access the logic to parse virtualenv 137 # requirements. Since that depends on "packaging", we add it to the path too. 138 # We need filelock for solving a virtualenv race condition 139 sys.path[0:0] = [ 140 os.path.join(topsrcdir, module) 141 for module in ( 142 os.path.join("python", "mach"), 143 os.path.join("testing", "mozbase", "mozfile"), 144 os.path.join("third_party", "python", "packaging"), 145 os.path.join("third_party", "python", "filelock"), 146 ) 147 ] 148 149 from mach.util import get_state_dir, get_virtualenv_base_dir, setenv 150 151 state_dir = _create_state_dir() 152 153 check_for_spaces(topsrcdir) 154 155 # See bug 1874208: 156 # Status messages from site.py break usages of `./mach environment`. 157 # We pass `quiet` only for it to work around this, so that all other 158 # commands can still write status messages. 159 if args and (args[0] == "environment" or "--quiet" in args): 160 quiet = True 161 else: 162 quiet = False 163 164 # normpath state_dir to normalize msys-style slashes. 165 _activate_python_environment( 166 topsrcdir, 167 lambda: os.path.normpath(get_state_dir(True, topsrcdir=topsrcdir)), 168 quiet=quiet, 169 ) 170 _maybe_activate_mozillabuild_environment() 171 172 import mach.main 173 from mach.command_util import ( 174 MACH_COMMANDS, 175 DetermineCommandVenvAction, 176 load_commands_from_spec, 177 ) 178 from mach.main import get_argument_parser 179 180 # Set a reasonable limit to the number of open files. 181 # 182 # Some linux systems set `ulimit -n` to a very high number, which works 183 # well for systems that run servers, but this setting causes performance 184 # problems when programs close file descriptors before forking, like 185 # Python's `subprocess.Popen(..., close_fds=True)` (close_fds=True is the 186 # default in Python 3), or Rust's stdlib. In some cases, Firefox does the 187 # same thing when spawning processes. We would prefer to lower this limit 188 # to avoid such performance problems; processes spawned by `mach` will 189 # inherit the limit set here. 190 # 191 # The Firefox build defaults the soft limit to 1024, except for builds that 192 # do LTO, where the soft limit is 8192. We're going to default to the 193 # latter, since people do occasionally do LTO builds on their local 194 # machines, and requiring them to discover another magical setting after 195 # setting up an LTO build in the first place doesn't seem good. 196 # 197 # This code mimics the code in taskcluster/scripts/run-task. 198 try: 199 import resource 200 201 # Keep the hard limit the same, though, allowing processes to change 202 # their soft limit if they need to (Firefox does, for instance). 203 (soft, hard) = resource.getrlimit(resource.RLIMIT_NOFILE) 204 # Permit people to override our default limit if necessary via 205 # MOZ_LIMIT_NOFILE, which is the same variable `run-task` uses. 206 limit = os.environ.get("MOZ_LIMIT_NOFILE") 207 if limit: 208 limit = int(limit) 209 else: 210 # If no explicit limit is given, use our default if it's less than 211 # the current soft limit. For instance, the default on macOS is 212 # 256, so we'd pick that rather than our default. 213 limit = min(soft, 8192) 214 # Now apply the limit, if it's different from the original one. 215 if limit != soft: 216 resource.setrlimit(resource.RLIMIT_NOFILE, (limit, hard)) 217 except ImportError: 218 # The resource module is UNIX only. 219 pass 220 221 def resolve_repository(): 222 import mozversioncontrol 223 224 try: 225 # This API doesn't respect the vcs binary choices from configure. 226 # If we ever need to use the VCS binary here, consider something 227 # more robust. 228 return mozversioncontrol.get_repository_object(path=topsrcdir) 229 except (mozversioncontrol.InvalidRepoPath, mozversioncontrol.MissingVCSTool): 230 return None 231 232 def pre_dispatch_handler(context, handler, args): 233 # If --disable-tests flag was enabled in the mozconfig used to compile 234 # the build, tests will be disabled. Instead of trying to run 235 # nonexistent tests then reporting a failure, this will prevent mach 236 # from progressing beyond this point. 237 if handler.category == "testing" and not handler.ok_if_tests_disabled: 238 from mozbuild.base import BuildEnvironmentNotFoundException 239 240 try: 241 from mozbuild.base import MozbuildObject 242 243 # all environments should have an instance of build object. 244 build = MozbuildObject.from_environment() 245 if build is not None and not getattr( 246 build, "substs", {"ENABLE_TESTS": True} 247 ).get("ENABLE_TESTS"): 248 print( 249 "Tests have been disabled with --disable-tests.\n" 250 + "Remove the flag, and re-compile to enable tests." 251 ) 252 sys.exit(1) 253 except BuildEnvironmentNotFoundException: 254 # likely automation environment, so do nothing. 255 pass 256 257 def post_dispatch_handler( 258 context, handler, instance, success, start_time, end_time, depth, args 259 ): 260 """Perform global operations after command dispatch. 261 262 263 For now, we will use this to handle build system telemetry. 264 """ 265 266 # Don't finalize telemetry data if this mach command was invoked as part of 267 # another mach command. 268 if depth != 1: 269 return 270 271 _finalize_telemetry_glean( 272 context.telemetry, 273 handler.name == "bootstrap", 274 success, 275 Path(topsrcdir), 276 Path(state_dir), 277 driver.settings, 278 ) 279 280 def populate_context(key=None): 281 if key is None: 282 return 283 if key == "state_dir": 284 return state_dir 285 286 if key == "local_state_dir": 287 return get_state_dir(specific_to_topsrcdir=True) 288 289 if key == "topdir": 290 return topsrcdir 291 292 if key == "pre_dispatch_handler": 293 return pre_dispatch_handler 294 295 if key == "post_dispatch_handler": 296 return post_dispatch_handler 297 298 if key == "repository": 299 return resolve_repository() 300 301 raise AttributeError(key) 302 303 # Note which process is top-level so that recursive mach invocations can avoid writing 304 # telemetry data. 305 if "MACH_MAIN_PID" not in os.environ: 306 setenv("MACH_MAIN_PID", str(os.getpid())) 307 308 driver = mach.main.Mach(os.getcwd()) 309 driver.populate_context_handler = populate_context 310 311 if not driver.settings_paths: 312 # default global machrc location 313 driver.settings_paths.append(state_dir) 314 # always load local repository configuration 315 driver.settings_paths.append(topsrcdir) 316 driver.load_settings() 317 318 aliases = driver.settings.alias 319 320 parser = get_argument_parser( 321 action=DetermineCommandVenvAction, 322 topsrcdir=topsrcdir, 323 ) 324 from argparse import Namespace 325 326 from mach.main import ( 327 SUGGESTED_COMMANDS_MESSAGE, 328 UNKNOWN_COMMAND_ERROR, 329 UnknownCommandError, 330 ) 331 332 namespace_in = Namespace() 333 setattr(namespace_in, "mach_command_aliases", aliases) 334 335 try: 336 namespace = parser.parse_args(args, namespace_in) 337 except UnknownCommandError as e: 338 suggestion_message = ( 339 SUGGESTED_COMMANDS_MESSAGE % (e.verb, ", ".join(e.suggested_commands)) 340 if e.suggested_commands 341 else "" 342 ) 343 print(UNKNOWN_COMMAND_ERROR % (e.verb, e.command, suggestion_message)) 344 sys.exit(1) 345 346 command_name = getattr(namespace, "command_name", None) 347 site_name = getattr(namespace, "site_name", "common") 348 command_site_manager = None 349 350 # the 'clobber' command needs to run in the 'mach' venv, so we 351 # don't want to activate any other virtualenv for it. 352 if command_name != "clobber": 353 from mach.site import CommandSiteManager 354 355 command_site_manager = CommandSiteManager.from_environment( 356 topsrcdir, 357 lambda: os.path.normpath(get_state_dir(True, topsrcdir=topsrcdir)), 358 site_name, 359 get_virtualenv_base_dir(topsrcdir), 360 quiet=quiet, 361 ) 362 363 command_site_manager.activate() 364 365 for category, meta in CATEGORIES.items(): 366 driver.define_category(category, meta["short"], meta["long"], meta["priority"]) 367 368 # Sparse checkouts may not have all mach_commands.py files. Ignore 369 # errors from missing files. Same for spidermonkey tarballs. 370 repo = resolve_repository() 371 if repo != "SOURCE": 372 missing_ok = ( 373 repo is not None and repo.sparse_checkout_present() 374 ) or os.path.exists(os.path.join(topsrcdir, "INSTALL")) 375 else: 376 missing_ok = () 377 378 commands_that_need_all_modules_loaded = [ 379 "busted", 380 "help", 381 "mach-commands", 382 "mach-completion", 383 "mach-debug-commands", 384 ] 385 386 def commands_to_load(top_level_command: str): 387 visited = set() 388 389 def find_downstream_commands_recursively(command: str): 390 if not MACH_COMMANDS.get(command): 391 return 392 393 if command in visited: 394 return 395 396 visited.add(command) 397 398 for command_dependency in MACH_COMMANDS[command].command_dependencies: 399 find_downstream_commands_recursively(command_dependency) 400 401 find_downstream_commands_recursively(top_level_command) 402 403 return list(visited) 404 405 if ( 406 command_name not in MACH_COMMANDS 407 or command_name in commands_that_need_all_modules_loaded 408 ): 409 command_modules_to_load = MACH_COMMANDS 410 else: 411 command_names_to_load = commands_to_load(command_name) 412 command_modules_to_load = { 413 command_name: MACH_COMMANDS[command_name] 414 for command_name in command_names_to_load 415 } 416 417 driver.command_site_manager = command_site_manager 418 load_commands_from_spec(command_modules_to_load, topsrcdir, missing_ok=missing_ok) 419 420 return driver 421 422 423 def _finalize_telemetry_glean( 424 telemetry, is_bootstrap, success, topsrcdir, state_dir, settings 425 ): 426 """Submit telemetry collected by Glean. 427 428 Finalizes some metrics (command success state and duration, system information) and 429 requests Glean to send the collected data. 430 """ 431 432 from mach.telemetry import MACH_METRICS_PATH, resolve_is_employee 433 from mozbuild.telemetry import ( 434 get_cpu_brand, 435 get_crowdstrike_running, 436 get_distro_and_version, 437 get_fleet_running, 438 get_psutil_stats, 439 get_shell_info, 440 get_vscode_running, 441 ) 442 443 moz_automation = any(e in os.environ for e in ("MOZ_AUTOMATION", "TASK_ID")) 444 445 mach_metrics = telemetry.metrics(MACH_METRICS_PATH) 446 mach_metrics.mach.duration.stop() 447 mach_metrics.mach.success.set(success) 448 mach_metrics.mach.moz_automation.set(moz_automation) 449 450 system_metrics = mach_metrics.mach.system 451 cpu_brand = get_cpu_brand() 452 if cpu_brand: 453 system_metrics.cpu_brand.set(cpu_brand) 454 distro, version = get_distro_and_version() 455 system_metrics.distro.set(distro) 456 system_metrics.distro_version.set(version) 457 458 vscode_terminal, ssh_connection = get_shell_info() 459 system_metrics.vscode_terminal.set(vscode_terminal) 460 system_metrics.ssh_connection.set(ssh_connection) 461 system_metrics.vscode_running.set(get_vscode_running()) 462 463 # Only collect Fleet and CrowdStrike metrics for Mozilla employees 464 if resolve_is_employee(topsrcdir, state_dir, settings): 465 system_metrics.fleet_running.set(get_fleet_running()) 466 system_metrics.crowdstrike_running.set(get_crowdstrike_running()) 467 468 has_psutil, logical_cores, physical_cores, memory_total = get_psutil_stats() 469 if has_psutil: 470 # psutil may not be available (we may not have been able to download 471 # a wheel or build it from source). 472 system_metrics.logical_cores.add(logical_cores) 473 if physical_cores is not None: 474 system_metrics.physical_cores.add(physical_cores) 475 if memory_total is not None: 476 system_metrics.memory.accumulate( 477 int(math.ceil(float(memory_total) / (1024 * 1024 * 1024))) 478 ) 479 telemetry.submit(is_bootstrap) 480 481 482 def _create_state_dir(): 483 # Global build system and mach state is stored in a central directory. By 484 # default, this is ~/.mozbuild. However, it can be defined via an 485 # environment variable. We detect first run (by lack of this directory 486 # existing) and notify the user that it will be created. The logic for 487 # creation is much simpler for the "advanced" environment variable use 488 # case. For default behavior, we educate users and give them an opportunity 489 # to react. 490 state_dir = os.environ.get("MOZBUILD_STATE_PATH") 491 if state_dir: 492 if not os.path.exists(state_dir): 493 print( 494 f"Creating global state directory from environment variable: {state_dir}" 495 ) 496 else: 497 state_dir = os.path.expanduser("~/.mozbuild") 498 if not os.path.exists(state_dir): 499 if not os.environ.get("MOZ_AUTOMATION"): 500 print(STATE_DIR_FIRST_RUN.format(state_dir)) 501 502 print(f"Creating default state directory: {state_dir}") 503 504 os.makedirs(state_dir, mode=0o770, exist_ok=True) 505 return state_dir 506 507 508 # Hook import such that .pyc/.pyo files without a corresponding .py file in 509 # the source directory are essentially ignored. See further below for details 510 # and caveats. 511 # Objdirs outside the source directory are ignored because in most cases, if 512 # a .pyc/.pyo file exists there, a .py file will be next to it anyways. 513 class FinderHook(MetaPathFinder): 514 def __init__(self, klass): 515 # Assume the source directory is the parent directory of the one 516 # containing this file. 517 self._source_dir = ( 518 os.path.normcase( 519 os.path.abspath(os.path.dirname(os.path.dirname(__file__))) 520 ) 521 + os.sep 522 ) 523 self.finder_class = klass 524 525 def find_spec(self, full_name, paths=None, target=None): 526 spec = self.finder_class.find_spec(full_name, paths, target) 527 528 # Some modules don't have an origin. 529 if spec is None or spec.origin is None: 530 return spec 531 532 # Normalize the origin path. 533 path = os.path.normcase(os.path.abspath(spec.origin)) 534 # Note: we could avoid normcase and abspath above for non pyc/pyo 535 # files, but those are actually rare, so it doesn't really matter. 536 if not path.endswith((".pyc", ".pyo")): 537 return spec 538 539 # Ignore modules outside our source directory 540 if not path.startswith(self._source_dir): 541 return spec 542 543 # If there is no .py corresponding to the .pyc/.pyo module we're 544 # resolving, remove the .pyc/.pyo file, and try again. 545 if not os.path.exists(spec.origin[:-1]): 546 if os.path.exists(spec.origin): 547 os.remove(spec.origin) 548 spec = self.finder_class.find_spec(full_name, paths, target) 549 550 return spec 551 552 553 # Additional hook for python >= 3.8's importlib.metadata. 554 class MetadataHook(FinderHook): 555 def find_distributions(self, *args, **kwargs): 556 return self.finder_class.find_distributions(*args, **kwargs) 557 558 559 def hook(finder): 560 has_find_spec = hasattr(finder, "find_spec") 561 has_find_distributions = hasattr(finder, "find_distributions") 562 if has_find_spec and has_find_distributions: 563 return MetadataHook(finder) 564 elif has_find_spec: 565 return FinderHook(finder) 566 return finder 567 568 569 sys.meta_path = [hook(c) for c in sys.meta_path]