mach_commands.py (22214B)
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 json 6 import logging 7 import re 8 import subprocess 9 import sys 10 import tempfile 11 from dataclasses import dataclass 12 from os import environ, makedirs 13 from pathlib import Path 14 from shutil import copytree, unpack_archive 15 16 import mozinfo 17 import mozinstall 18 import requests 19 from gecko_taskgraph.transforms.update_test import ReleaseType 20 from mach.decorators import Command, CommandArgument 21 from mozbuild.base import BinaryNotFoundException 22 from mozlog.structured import commandline 23 from mozrelease.update_verify import UpdateVerifyConfig 24 25 STAGING_POLICY_PAYLOAD = { 26 "policies": { 27 "AppUpdateURL": "https://stage.balrog.nonprod.cloudops.mozgcp.net/update/6/Firefox/%VERSION%/%BUILD_ID%/%BUILD_TARGET%/%LOCALE%/%CHANNEL%/%OS_VERSION%/%SYSTEM_CAPABILITIES%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/update.xml" 28 } 29 } 30 31 32 @dataclass 33 class UpdateTestConfig: 34 """Track all needed test config""" 35 36 channel: str = "release-localtest" 37 mar_channel: str = "firefox-mozilla-release" 38 app_dir_name: str = "fx_test" 39 manifest_loc: str = "testing/update/manifest.toml" 40 # Where in the list of allowable source versions should we default to testing 41 source_version_position: int = -3 42 # How many major versions back can we test? 43 major_version_range: int = 3 44 locale: str = "en-US" 45 update_verify_file: str = "update-verify.cfg" 46 update_verify_config = None 47 config_source = None 48 release_type: ReleaseType = ReleaseType.release 49 esr_version = None 50 staging_update = False 51 52 def __post_init__(self): 53 if environ.get("UPLOAD_DIR"): 54 self.artifact_dir = Path(environ.get("UPLOAD_DIR"), "update-test") 55 makedirs(self.artifact_dir, exist_ok=True) 56 self.version_info_path = Path( 57 self.artifact_dir, environ.get("VERSION_LOG_FILENAME") 58 ) 59 60 else: 61 self.version_info_path = None 62 63 def set_channel(self, new_channel, esr_version=None): 64 self.channel = new_channel 65 if self.channel.startswith("release"): 66 self.mar_channel = "firefox-mozilla-release" 67 self.release_type = ReleaseType.release 68 elif self.channel.startswith("beta"): 69 self.mar_channel = "firefox-mozilla-beta,firefox-mozilla-release" 70 self.release_type = ReleaseType.beta 71 elif self.channel.startswith("esr"): 72 self.mar_channel = "firefox-mozilla-esr,firefox-mozilla-release" 73 self.release_type = ReleaseType.esr 74 self.esr_version = esr_version 75 else: 76 self.mar_channel = "firefox-mozilla-central" 77 self.release_type = ReleaseType.other 78 79 def set_ftp_info(self): 80 """Get server URL and template for downloading application/installer""" 81 # The %release% string will be replaced by a version number later 82 platform, executable_name = get_fx_executable_name("%release%") 83 if self.update_verify_config: 84 full_info_release = next( 85 r for r in self.update_verify_config.releases if r.get("from") 86 ) 87 executable_name = Path(full_info_release["from"]).name 88 release_number = full_info_release["from"].split("/")[3] 89 executable_name = executable_name.replace(release_number, "%release%") 90 executable_name = executable_name.replace(".bz2", ".xz") 91 executable_name = executable_name.replace(".pkg", ".dmg") 92 executable_name = executable_name.replace(".msi", ".exe") 93 template = ( 94 f"https://archive.mozilla.org/pub/firefox/releases/%release%/{platform}/{self.locale}/" 95 + executable_name 96 ) 97 98 self.ftp_server = template.split("%release%")[0] 99 self.url_template = template 100 101 def add_update_verify_config(self, filename=None): 102 """Parse update-verify.cfg. Obtain a copy if not found in dep/commandline""" 103 if not filename: 104 platform, _ = get_fx_executable_name("") 105 config_route = ( 106 "https://firefox-ci-tc.services.mozilla.com/api/" 107 "index/v1/task/gecko.v2.mozilla-central.latest.firefox." 108 f"update-verify-config-firefox-{platform}-{self.channel}" 109 "/artifacts/public%2Fbuild%2Fupdate-verify.cfg" 110 ) 111 resp = requests.get(config_route) 112 try: 113 resp.raise_for_status() 114 filename = Path(self.tempdir, self.update_verify_file) 115 with open(filename, "wb") as fh: 116 fh.write(resp.content) 117 self.config_source = "route" 118 except requests.exceptions.HTTPError: 119 return None 120 121 uv_config = UpdateVerifyConfig() 122 uv_config.read(filename) 123 self.update_verify_config = uv_config 124 # Beta display version example "140.0 Beta 3", Release just like "140.0" 125 if "Beta" in uv_config.to_display_version: 126 major, beta = uv_config.to_display_version.split(" Beta ") 127 self.target_version = f"{major}b{beta}" 128 else: 129 self.target_version = uv_config.to_display_version 130 131 132 def setup_update_argument_parser(): 133 from marionette_harness.runtests import MarionetteArguments 134 from mozlog.structured import commandline 135 136 parser = MarionetteArguments() 137 commandline.add_logging_group(parser) 138 139 return parser 140 141 142 def get_fx_executable_name(version): 143 """Given a version string, get the expected downloadable name for the os""" 144 if mozinfo.os == "mac": 145 executable_platform = "mac" 146 executable_name = f"Firefox {version}.dmg" 147 148 if mozinfo.os == "linux": 149 executable_platform = "linux-x86_64" 150 try: 151 assert int(version.split(".")[0]) < 135 152 executable_name = f"firefox-{version}.tar.bz2" 153 except (AssertionError, ValueError): 154 executable_name = f"firefox-{version}.tar.xz" 155 156 if mozinfo.os == "win": 157 if mozinfo.arch == "aarch64": 158 executable_platform = "win64-aarch64" 159 elif mozinfo.bits == "64": 160 executable_platform = "win64" 161 else: 162 executable_platform = "win32" 163 executable_name = f"Firefox Setup {version}.exe" 164 165 return executable_platform, executable_name.replace(" ", "%20") 166 167 168 def get_valid_source_versions(config): 169 """ 170 Get a list of versions to update from, based on config. 171 For beta, this means a list of betas, not releases. 172 For ESR, this means a list of ESR versions where major version matches target. 173 """ 174 ftp_content = requests.get(config.ftp_server).content.decode() 175 # All versions start with e.g. 140.0, so beta and release can be int'ed 176 ver_head, ver_tail = config.target_version.split(".", 1) 177 latest_version = int(ver_head) 178 latest_minor_str = "" 179 # Versions like 130.10.1 and 130.0 are possible, capture the minor number 180 for c in ver_tail: 181 try: 182 int(c) 183 latest_minor_str = latest_minor_str + c 184 except ValueError: 185 break 186 187 valid_versions: list[str] = [] 188 for major in range(latest_version - config.major_version_range, latest_version + 1): 189 minor_versions = [] 190 if config.release_type == ReleaseType.esr and major != latest_version: 191 continue 192 for minor in range(0, 11): 193 if ( 194 config.release_type == ReleaseType.release 195 and f"/{major}.{minor}/" in ftp_content 196 ): 197 if f"{major}.{minor}" == config.target_version: 198 break 199 minor_versions.append(minor) 200 valid_versions.append(f"{major}.{minor}") 201 elif config.release_type == ReleaseType.esr and re.compile( 202 rf"/{major}\.{minor}.*/" 203 ).search(ftp_content): 204 minor_versions.append(minor) 205 if f"/{major}.{minor}esr" in ftp_content: 206 valid_versions.append(f"{major}.{minor}") 207 elif config.release_type == ReleaseType.beta and minor == 0: 208 # Release 1xx.0 is not available, but 1xx.0b1 is: 209 minor_versions.append(minor) 210 211 sep = "b" if config.release_type == ReleaseType.beta else "." 212 213 for minor in minor_versions: 214 for dot in range(0, 15): 215 if f"{major}.{minor}{sep}{dot}" == config.target_version: 216 break 217 if config.release_type == ReleaseType.esr: 218 if f"/{major}.{minor}{sep}{dot}esr/" in ftp_content: 219 valid_versions.append(f"{major}.{minor}{sep}{dot}") 220 elif f"/{major}.{minor}{sep}{dot}/" in ftp_content: 221 valid_versions.append(f"{major}.{minor}{sep}{dot}") 222 223 # Only test beta versions if channel is beta 224 if config.release_type == ReleaseType.beta: 225 valid_versions = [ver for ver in valid_versions if "b" in ver] 226 elif config.release_type == ReleaseType.esr: 227 valid_versions = [ 228 f"{ver}esr" if not ver.endswith("esr") else ver for ver in valid_versions 229 ] 230 valid_versions.sort() 231 while len(valid_versions) < 5: 232 valid_versions.insert(0, valid_versions[0]) 233 return valid_versions 234 235 236 def get_binary_path(config: UpdateTestConfig, **kwargs) -> str: 237 # Install correct Fx and return executable location 238 if not config.source_version: 239 if config.update_verify_config: 240 # In future, we can modify this for watershed logic 241 source_versions = get_valid_source_versions(config) 242 else: 243 response = requests.get( 244 "https://product-details.mozilla.org/1.0/firefox_versions.json" 245 ) 246 response.raise_for_status() 247 product_details = response.json() 248 if config.release_type == ReleaseType.beta: 249 target_channel = "LATEST_FIREFOX_RELEASED_DEVEL_VERSION" 250 elif config.release_type == ReleaseType.esr: 251 current_esr = product_details.get("FIREFOX_ESR").split(".")[0] 252 if config.esr_version == current_esr: 253 target_channel = "FIREFOX_ESR" 254 else: 255 target_channel = f"FIREFOX_ESR{config.esr_version}" 256 else: 257 target_channel = "LATEST_FIREFOX_VERSION" 258 259 target_version = product_details.get(target_channel) 260 config.target_version = target_version 261 source_versions = get_valid_source_versions(config) 262 263 # NB below: value 0 will get you the oldest acceptable version, not the newest 264 source_version = source_versions[config.source_version_position] 265 config.source_version = source_version 266 platform, executable_name = get_fx_executable_name(config.source_version) 267 268 os_edition = f"{mozinfo.os} {mozinfo.os_version}" 269 if config.version_info_path: 270 # Only write the file on non-local runs 271 print(f"Writing source info to {config.version_info_path.resolve()}...") 272 with config.version_info_path.open("a") as fh: 273 fh.write(f"Test Type: {kwargs.get('test_type')}\n") 274 fh.write(f"UV Config Source: {config.config_source}\n") 275 fh.write(f"Region: {config.locale}\n") 276 fh.write(f"Source Version: {config.source_version}\n") 277 fh.write(f"Platform: {os_edition}\n") 278 with config.version_info_path.open() as fh: 279 print("".join(fh.readlines())) 280 else: 281 print( 282 f"Region: {config.locale}\nSource Version: {source_version}\nPlatform: {os_edition}" 283 ) 284 285 executable_url = config.url_template.replace("%release%", config.source_version) 286 287 installer_filename = Path(config.tempdir, Path(executable_url).name) 288 installed_app_dir = Path(config.tempdir, config.app_dir_name) 289 print(f"Downloading Fx from {executable_url}...") 290 response = requests.get(executable_url) 291 response.raise_for_status() 292 print(f"Download successful, status {response.status_code}") 293 with installer_filename.open("wb") as fh: 294 fh.write(response.content) 295 fx_location = mozinstall.install(installer_filename, installed_app_dir) 296 print(f"Firefox installed to {fx_location}") 297 298 if config.staging_update: 299 print("Writing enterprise policy for update server") 300 fx_path = Path(fx_location) 301 policy_path = None 302 if mozinfo.os in ["linux", "win"]: 303 policy_path = fx_path / "distribution" 304 elif mozinfo.os == "mac": 305 policy_path = fx_path / "Contents" / "Resources" / "distribution" 306 else: 307 raise ValueError("Invalid OS.") 308 makedirs(policy_path) 309 policy_loc = policy_path / "policies.json" 310 print(f"Creating {policy_loc}...") 311 with policy_loc.open("w") as fh: 312 json.dump(STAGING_POLICY_PAYLOAD, fh, indent=2) 313 with policy_loc.open() as fh: 314 print(fh.read()) 315 316 return fx_location 317 318 319 @Command( 320 "update-test", 321 category="testing", 322 virtualenv_name="update", 323 description="Test if the version can be updated to the latest patch successfully,", 324 parser=setup_update_argument_parser, 325 ) 326 @CommandArgument("--binary-path", help="Firefox executable path is needed") 327 @CommandArgument("--test-type", default="Base", help="Base/Background") 328 @CommandArgument("--source-version", help="Firefox build version to update from") 329 @CommandArgument( 330 "--source-versions-back", 331 help="Update from the version of Fx $N releases before current", 332 ) 333 @CommandArgument("--source-locale", help="Firefox build locale to update from") 334 @CommandArgument("--channel", default="release-localtest", help="Update channel to use") 335 @CommandArgument( 336 "--esr-version", 337 help="ESR version, if set with --channel=esr, will only update within ESR major version", 338 ) 339 @CommandArgument("--uv-config-file", help="Update Verify config file") 340 @CommandArgument( 341 "--use-balrog-staging", action="store_true", help="Update from staging, not prod" 342 ) 343 def build(command_context, binary_path, **kwargs): 344 config = UpdateTestConfig() 345 346 fetches = environ.get("MOZ_FETCHES_DIR") 347 if fetches: 348 config_file = Path(fetches, config.update_verify_file) 349 if kwargs.get("uv_config_file"): 350 config.config_source = "commandline" 351 elif config_file.is_file(): 352 kwargs["uv_config_file"] = config_file 353 config.config_source = "kind_dependency" 354 355 if not kwargs.get("uv_config_file"): 356 config.add_update_verify_config() 357 else: 358 config.add_update_verify_config(kwargs["uv_config_file"]) 359 # TODO: update tests to check against config version, not update server resp 360 # kwargs["to_display_version"] = uv_config.to_display_version 361 362 if kwargs.get("source_locale"): 363 config.locale = kwargs["source_locale"] 364 365 if kwargs.get("source_versions_back"): 366 config.source_version_position = -int(kwargs["source_versions_back"]) 367 368 if kwargs.get("source_version"): 369 config.source_version = kwargs["source_version"] 370 else: 371 config.source_version = None 372 373 config.set_ftp_info() 374 375 tempdir = tempfile.TemporaryDirectory() 376 # If we have a symlink to the tmp directory, resolve it 377 tempdir_name = str(Path(tempdir.name).resolve()) 378 config.tempdir = tempdir_name 379 test_type = kwargs.get("test_type") 380 381 if kwargs.get("use_balrog_staging"): 382 config.staging_update = True 383 384 # Select update channel 385 if kwargs.get("channel"): 386 config.set_channel(kwargs["channel"], kwargs.get("esr_version")) 387 # if (config.beta and not config.update_verify_config): 388 # logging.error("Non-release testing on local machines is not supported.") 389 # sys.exit(1) 390 391 # Run the specified test in the suite 392 with open(config.manifest_loc) as f: 393 old_content = f.read() 394 395 with open(config.manifest_loc, "w") as f: 396 f.write("[DEFAULT]\n\n") 397 if test_type.lower() == "base": 398 f.write('["test_apply_update.py"]') 399 elif test_type.lower() == "background": 400 f.write('["test_background_update.py"]') 401 else: 402 logging.ERROR("Invalid test type") 403 sys.exit(1) 404 405 config.dir = command_context.topsrcdir 406 407 if mozinfo.os == "win": 408 config.log_file_path = bits_pretest() 409 try: 410 kwargs["binary"] = set_up( 411 binary_path or get_binary_path(config, **kwargs), config 412 ) 413 # TODO: change tests to check against config, not update server response 414 # if not kwargs.get("to_display_version"): 415 # kwargs["to_display_version"] = config.target_version 416 return run_tests(config, **kwargs) 417 except BinaryNotFoundException as e: 418 command_context.log( 419 logging.ERROR, 420 "update-test", 421 {"error": str(e)}, 422 "ERROR: {error}", 423 ) 424 command_context.log(logging.INFO, "update-test", {"help": e.help()}, "{help}") 425 return 1 426 finally: 427 with open(config.manifest_loc, "w") as f: 428 f.write(old_content) 429 if mozinfo.os == "win": 430 bits_posttest(config) 431 tempdir.cleanup() 432 433 434 def run_tests(config, **kwargs): 435 from argparse import Namespace 436 437 from marionette_harness.runtests import MarionetteHarness, MarionetteTestRunner 438 439 args = Namespace() 440 args.binary = kwargs["binary"] 441 args.logger = kwargs.pop("log", None) 442 if not args.logger: 443 args.logger = commandline.setup_logging( 444 "Update Tests", args, {"mach": sys.stdout} 445 ) 446 447 for k, v in kwargs.items(): 448 setattr(args, k, v) 449 450 args.tests = [ 451 Path( 452 config.dir, 453 config.manifest_loc, 454 ) 455 ] 456 args.gecko_log = "-" 457 458 parser = setup_update_argument_parser() 459 parser.verify_usage(args) 460 461 failed = MarionetteHarness(MarionetteTestRunner, args=vars(args)).run() 462 if config.version_info_path: 463 with config.version_info_path.open("a") as fh: 464 fh.write(f"Status: {'failed' if failed else 'passed'}\n") 465 if failed > 0: 466 return 1 467 return 0 468 469 470 def copy_macos_channelprefs(config) -> str: 471 # Copy ChannelPrefs.framework to the correct location on MacOS, 472 # return the location of the Fx executable 473 installed_app_dir = Path(config.tempdir, config.app_dir_name) 474 475 bz_channelprefs_link = "https://bugzilla.mozilla.org/attachment.cgi?id=9417387" 476 477 resp = requests.get(bz_channelprefs_link) 478 download_target = Path(config.tempdir, "channelprefs.zip") 479 unpack_target = str(download_target).rsplit(".", 1)[0] 480 with download_target.open("wb") as fh: 481 fh.write(resp.content) 482 483 unpack_archive(download_target, unpack_target) 484 print( 485 f"Downloaded channelprefs.zip to {download_target} and unpacked to {unpack_target}" 486 ) 487 488 src = Path(config.tempdir, "channelprefs", config.channel) 489 dst = Path(installed_app_dir, "Firefox.app", "Contents", "Frameworks") 490 491 Path(installed_app_dir, "Firefox.app").chmod(455) # rwx for all users 492 493 print(f"Copying ChannelPrefs.framework from {src} to {dst}") 494 copytree( 495 Path(src, "ChannelPrefs.framework"), 496 Path(dst, "ChannelPrefs.framework"), 497 dirs_exist_ok=True, 498 ) 499 500 # test against the binary that was copied to local 501 fx_executable = Path( 502 installed_app_dir, "Firefox.app", "Contents", "MacOS", "firefox" 503 ) 504 return str(fx_executable) 505 506 507 def set_up(binary_path, config): 508 # Set channel prefs for all OS targets 509 binary_path_str = mozinstall.get_binary(binary_path, "Firefox") 510 print(f"Binary path: {binary_path_str}") 511 binary_dir = Path(binary_path_str).absolute().parent 512 513 if mozinfo.os == "mac": 514 return copy_macos_channelprefs(config) 515 else: 516 with Path(binary_dir, "update-settings.ini").open("w") as f: 517 f.write("[Settings]\n") 518 f.write(f"ACCEPTED_MAR_CHANNEL_IDS={config.mar_channel}") 519 520 with Path(binary_dir, "defaults", "pref", "channel-prefs.js").open("w") as f: 521 f.write(f'pref("app.update.channel", "{config.channel}");') 522 523 return binary_path_str 524 525 526 def bits_pretest(): 527 # Check that BITS is enabled 528 for line in subprocess.check_output(["sc", "qc", "BITS"], text=True).split("\n"): 529 if "START_TYPE" in line: 530 assert "DISABLED" not in line 531 # Write all logs to a file to check for results later 532 log_file = tempfile.NamedTemporaryFile(mode="wt", delete=False) 533 sys.stdout = log_file 534 return log_file 535 536 537 def bits_posttest(config): 538 if config.staging_update: 539 # If we are in try, we didn't run the full test and BITS will fail. 540 return None 541 config.log_file_path.close() 542 sys.stdout = sys.__stdout__ 543 544 failed = 0 545 try: 546 # Check that all the expected logs are present 547 downloader_regex = r"UpdateService:makeBitsRequest - Starting BITS download with url: https?:\/\/.+, updateDir: .+, filename: .+" 548 bits_download_regex = ( 549 r"Downloader:downloadUpdate - BITS download running. BITS ID: {.+}" 550 ) 551 552 with open(config.log_file_path.name, errors="ignore") as f: 553 logs = f.read() 554 assert re.search(downloader_regex, logs) 555 assert re.search(bits_download_regex, logs) 556 assert ( 557 "AUS:SVC Downloader:_canUseBits - Not using BITS because it was already tried" 558 not in logs 559 ) 560 assert ( 561 "AUS:SVC Downloader:downloadUpdate - Starting nsIIncrementalDownload with url:" 562 not in logs 563 ) 564 except (UnicodeDecodeError, AssertionError) as e: 565 failed = 1 566 logging.error(e.__traceback__) 567 finally: 568 Path(config.log_file_path.name).unlink() 569 570 if config.version_info_path: 571 with config.version_info_path.open("a") as fh: 572 fh.write(f"BITS: {'failed' if failed else 'passed'}\n") 573 574 if failed: 575 sys.exit(1)