tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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)