575 lines
22 KiB
Python
575 lines
22 KiB
Python
# 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 json
|
|
import logging
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from os import environ, makedirs
|
|
from pathlib import Path
|
|
from shutil import copytree, unpack_archive
|
|
|
|
import mozinfo
|
|
import mozinstall
|
|
import requests
|
|
from gecko_taskgraph.transforms.update_test import ReleaseType
|
|
from mach.decorators import Command, CommandArgument
|
|
from mozbuild.base import BinaryNotFoundException
|
|
from mozlog.structured import commandline
|
|
from mozrelease.update_verify import UpdateVerifyConfig
|
|
|
|
STAGING_POLICY_PAYLOAD = {
|
|
"policies": {
|
|
"AppUpdateURL": "https://stage.balrog.nonprod.cloudops.mozgcp.net/update/6/IceCat/%VERSION%/%BUILD_ID%/%BUILD_TARGET%/%LOCALE%/%CHANNEL%/%OS_VERSION%/%SYSTEM_CAPABILITIES%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/update.xml"
|
|
}
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class UpdateTestConfig:
|
|
"""Track all needed test config"""
|
|
|
|
channel: str = "release-localtest"
|
|
mar_channel: str = "icecat-mozilla-release"
|
|
app_dir_name: str = "fx_test"
|
|
manifest_loc: str = "testing/update/manifest.toml"
|
|
# Where in the list of allowable source versions should we default to testing
|
|
source_version_position: int = -3
|
|
# How many major versions back can we test?
|
|
major_version_range: int = 3
|
|
locale: str = "en-US"
|
|
update_verify_file: str = "update-verify.cfg"
|
|
update_verify_config = None
|
|
config_source = None
|
|
release_type: ReleaseType = ReleaseType.release
|
|
esr_version = None
|
|
staging_update = False
|
|
|
|
def __post_init__(self):
|
|
if environ.get("UPLOAD_DIR"):
|
|
self.artifact_dir = Path(environ.get("UPLOAD_DIR"), "update-test")
|
|
makedirs(self.artifact_dir, exist_ok=True)
|
|
self.version_info_path = Path(
|
|
self.artifact_dir, environ.get("VERSION_LOG_FILENAME")
|
|
)
|
|
|
|
else:
|
|
self.version_info_path = None
|
|
|
|
def set_channel(self, new_channel, esr_version=None):
|
|
self.channel = new_channel
|
|
if self.channel.startswith("release"):
|
|
self.mar_channel = "icecat-mozilla-release"
|
|
self.release_type = ReleaseType.release
|
|
elif self.channel.startswith("beta"):
|
|
self.mar_channel = "icecat-mozilla-beta,icecat-mozilla-release"
|
|
self.release_type = ReleaseType.beta
|
|
elif self.channel.startswith("esr"):
|
|
self.mar_channel = "icecat-mozilla-esr,icecat-mozilla-release"
|
|
self.release_type = ReleaseType.esr
|
|
self.esr_version = esr_version
|
|
else:
|
|
self.mar_channel = "icecat-mozilla-central"
|
|
self.release_type = ReleaseType.other
|
|
|
|
def set_ftp_info(self):
|
|
"""Get server URL and template for downloading application/installer"""
|
|
# The %release% string will be replaced by a version number later
|
|
platform, executable_name = get_fx_executable_name("%release%")
|
|
if self.update_verify_config:
|
|
full_info_release = next(
|
|
r for r in self.update_verify_config.releases if r.get("from")
|
|
)
|
|
executable_name = Path(full_info_release["from"]).name
|
|
release_number = full_info_release["from"].split("/")[3]
|
|
executable_name = executable_name.replace(release_number, "%release%")
|
|
executable_name = executable_name.replace(".bz2", ".xz")
|
|
executable_name = executable_name.replace(".pkg", ".dmg")
|
|
executable_name = executable_name.replace(".msi", ".exe")
|
|
template = (
|
|
f"https://archive.mozilla.org/pub/icecat/releases/%release%/{platform}/{self.locale}/"
|
|
+ executable_name
|
|
)
|
|
|
|
self.ftp_server = template.split("%release%")[0]
|
|
self.url_template = template
|
|
|
|
def add_update_verify_config(self, filename=None):
|
|
"""Parse update-verify.cfg. Obtain a copy if not found in dep/commandline"""
|
|
if not filename:
|
|
platform, _ = get_fx_executable_name("")
|
|
config_route = (
|
|
"https://icecat-ci-tc.services.mozilla.com/api/"
|
|
"index/v1/task/gecko.v2.mozilla-central.latest.icecat."
|
|
f"update-verify-config-icecat-{platform}-{self.channel}"
|
|
"/artifacts/public%2Fbuild%2Fupdate-verify.cfg"
|
|
)
|
|
resp = requests.get(config_route)
|
|
try:
|
|
resp.raise_for_status()
|
|
filename = Path(self.tempdir, self.update_verify_file)
|
|
with open(filename, "wb") as fh:
|
|
fh.write(resp.content)
|
|
self.config_source = "route"
|
|
except requests.exceptions.HTTPError:
|
|
return None
|
|
|
|
uv_config = UpdateVerifyConfig()
|
|
uv_config.read(filename)
|
|
self.update_verify_config = uv_config
|
|
# Beta display version example "140.0 Beta 3", Release just like "140.0"
|
|
if "Beta" in uv_config.to_display_version:
|
|
major, beta = uv_config.to_display_version.split(" Beta ")
|
|
self.target_version = f"{major}b{beta}"
|
|
else:
|
|
self.target_version = uv_config.to_display_version
|
|
|
|
|
|
def setup_update_argument_parser():
|
|
from marionette_harness.runtests import MarionetteArguments
|
|
from mozlog.structured import commandline
|
|
|
|
parser = MarionetteArguments()
|
|
commandline.add_logging_group(parser)
|
|
|
|
return parser
|
|
|
|
|
|
def get_fx_executable_name(version):
|
|
"""Given a version string, get the expected downloadable name for the os"""
|
|
if mozinfo.os == "mac":
|
|
executable_platform = "mac"
|
|
executable_name = f"IceCat {version}.dmg"
|
|
|
|
if mozinfo.os == "linux":
|
|
executable_platform = "linux-x86_64"
|
|
try:
|
|
assert int(version.split(".")[0]) < 135
|
|
executable_name = f"icecat-{version}.tar.bz2"
|
|
except (AssertionError, ValueError):
|
|
executable_name = f"icecat-{version}.tar.xz"
|
|
|
|
if mozinfo.os == "win":
|
|
if mozinfo.arch == "aarch64":
|
|
executable_platform = "win64-aarch64"
|
|
elif mozinfo.bits == "64":
|
|
executable_platform = "win64"
|
|
else:
|
|
executable_platform = "win32"
|
|
executable_name = f"IceCat Setup {version}.exe"
|
|
|
|
return executable_platform, executable_name.replace(" ", "%20")
|
|
|
|
|
|
def get_valid_source_versions(config):
|
|
"""
|
|
Get a list of versions to update from, based on config.
|
|
For beta, this means a list of betas, not releases.
|
|
For ESR, this means a list of ESR versions where major version matches target.
|
|
"""
|
|
ftp_content = requests.get(config.ftp_server).content.decode()
|
|
# All versions start with e.g. 140.0, so beta and release can be int'ed
|
|
ver_head, ver_tail = config.target_version.split(".", 1)
|
|
latest_version = int(ver_head)
|
|
latest_minor_str = ""
|
|
# Versions like 130.10.1 and 130.0 are possible, capture the minor number
|
|
for c in ver_tail:
|
|
try:
|
|
int(c)
|
|
latest_minor_str = latest_minor_str + c
|
|
except ValueError:
|
|
break
|
|
|
|
valid_versions: list[str] = []
|
|
for major in range(latest_version - config.major_version_range, latest_version + 1):
|
|
minor_versions = []
|
|
if config.release_type == ReleaseType.esr and major != latest_version:
|
|
continue
|
|
for minor in range(0, 11):
|
|
if (
|
|
config.release_type == ReleaseType.release
|
|
and f"/{major}.{minor}/" in ftp_content
|
|
):
|
|
if f"{major}.{minor}" == config.target_version:
|
|
break
|
|
minor_versions.append(minor)
|
|
valid_versions.append(f"{major}.{minor}")
|
|
elif config.release_type == ReleaseType.esr and re.compile(
|
|
rf"/{major}\.{minor}.*/"
|
|
).search(ftp_content):
|
|
minor_versions.append(minor)
|
|
if f"/{major}.{minor}esr" in ftp_content:
|
|
valid_versions.append(f"{major}.{minor}")
|
|
elif config.release_type == ReleaseType.beta and minor == 0:
|
|
# Release 1xx.0 is not available, but 1xx.0b1 is:
|
|
minor_versions.append(minor)
|
|
|
|
sep = "b" if config.release_type == ReleaseType.beta else "."
|
|
|
|
for minor in minor_versions:
|
|
for dot in range(0, 15):
|
|
if f"{major}.{minor}{sep}{dot}" == config.target_version:
|
|
break
|
|
if config.release_type == ReleaseType.esr:
|
|
if f"/{major}.{minor}{sep}{dot}esr/" in ftp_content:
|
|
valid_versions.append(f"{major}.{minor}{sep}{dot}")
|
|
elif f"/{major}.{minor}{sep}{dot}/" in ftp_content:
|
|
valid_versions.append(f"{major}.{minor}{sep}{dot}")
|
|
|
|
# Only test beta versions if channel is beta
|
|
if config.release_type == ReleaseType.beta:
|
|
valid_versions = [ver for ver in valid_versions if "b" in ver]
|
|
elif config.release_type == ReleaseType.esr:
|
|
valid_versions = [
|
|
f"{ver}esr" if not ver.endswith("esr") else ver for ver in valid_versions
|
|
]
|
|
valid_versions.sort()
|
|
while len(valid_versions) < 5:
|
|
valid_versions.insert(0, valid_versions[0])
|
|
return valid_versions
|
|
|
|
|
|
def get_binary_path(config: UpdateTestConfig, **kwargs) -> str:
|
|
# Install correct Fx and return executable location
|
|
if not config.source_version:
|
|
if config.update_verify_config:
|
|
# In future, we can modify this for watershed logic
|
|
source_versions = get_valid_source_versions(config)
|
|
else:
|
|
response = requests.get(
|
|
"https://product-details.mozilla.org/1.0/icecat_versions.json"
|
|
)
|
|
response.raise_for_status()
|
|
product_details = response.json()
|
|
if config.release_type == ReleaseType.beta:
|
|
target_channel = "LATEST_ICECAT_RELEASED_DEVEL_VERSION"
|
|
elif config.release_type == ReleaseType.esr:
|
|
current_esr = product_details.get("ICECAT_ESR").split(".")[0]
|
|
if config.esr_version == current_esr:
|
|
target_channel = "ICECAT_ESR"
|
|
else:
|
|
target_channel = f"ICECAT_ESR{config.esr_version}"
|
|
else:
|
|
target_channel = "LATEST_ICECAT_VERSION"
|
|
|
|
target_version = product_details.get(target_channel)
|
|
config.target_version = target_version
|
|
source_versions = get_valid_source_versions(config)
|
|
|
|
# NB below: value 0 will get you the oldest acceptable version, not the newest
|
|
source_version = source_versions[config.source_version_position]
|
|
config.source_version = source_version
|
|
platform, executable_name = get_fx_executable_name(config.source_version)
|
|
|
|
os_edition = f"{mozinfo.os} {mozinfo.os_version}"
|
|
if config.version_info_path:
|
|
# Only write the file on non-local runs
|
|
print(f"Writing source info to {config.version_info_path.resolve()}...")
|
|
with config.version_info_path.open("a") as fh:
|
|
fh.write(f"Test Type: {kwargs.get('test_type')}\n")
|
|
fh.write(f"UV Config Source: {config.config_source}\n")
|
|
fh.write(f"Region: {config.locale}\n")
|
|
fh.write(f"Source Version: {config.source_version}\n")
|
|
fh.write(f"Platform: {os_edition}\n")
|
|
with config.version_info_path.open() as fh:
|
|
print("".join(fh.readlines()))
|
|
else:
|
|
print(
|
|
f"Region: {config.locale}\nSource Version: {source_version}\nPlatform: {os_edition}"
|
|
)
|
|
|
|
executable_url = config.url_template.replace("%release%", config.source_version)
|
|
|
|
installer_filename = Path(config.tempdir, Path(executable_url).name)
|
|
installed_app_dir = Path(config.tempdir, config.app_dir_name)
|
|
print(f"Downloading Fx from {executable_url}...")
|
|
response = requests.get(executable_url)
|
|
response.raise_for_status()
|
|
print(f"Download successful, status {response.status_code}")
|
|
with installer_filename.open("wb") as fh:
|
|
fh.write(response.content)
|
|
fx_location = mozinstall.install(installer_filename, installed_app_dir)
|
|
print(f"IceCat installed to {fx_location}")
|
|
|
|
if config.staging_update:
|
|
print("Writing enterprise policy for update server")
|
|
fx_path = Path(fx_location)
|
|
policy_path = None
|
|
if mozinfo.os in ["linux", "win"]:
|
|
policy_path = fx_path / "distribution"
|
|
elif mozinfo.os == "mac":
|
|
policy_path = fx_path / "Contents" / "Resources" / "distribution"
|
|
else:
|
|
raise ValueError("Invalid OS.")
|
|
makedirs(policy_path)
|
|
policy_loc = policy_path / "policies.json"
|
|
print(f"Creating {policy_loc}...")
|
|
with policy_loc.open("w") as fh:
|
|
json.dump(STAGING_POLICY_PAYLOAD, fh, indent=2)
|
|
with policy_loc.open() as fh:
|
|
print(fh.read())
|
|
|
|
return fx_location
|
|
|
|
|
|
@Command(
|
|
"update-test",
|
|
category="testing",
|
|
virtualenv_name="update",
|
|
description="Test if the version can be updated to the latest patch successfully,",
|
|
parser=setup_update_argument_parser,
|
|
)
|
|
@CommandArgument("--binary-path", help="IceCat executable path is needed")
|
|
@CommandArgument("--test-type", default="Base", help="Base/Background")
|
|
@CommandArgument("--source-version", help="IceCat build version to update from")
|
|
@CommandArgument(
|
|
"--source-versions-back",
|
|
help="Update from the version of Fx $N releases before current",
|
|
)
|
|
@CommandArgument("--source-locale", help="IceCat build locale to update from")
|
|
@CommandArgument("--channel", default="release-localtest", help="Update channel to use")
|
|
@CommandArgument(
|
|
"--esr-version",
|
|
help="ESR version, if set with --channel=esr, will only update within ESR major version",
|
|
)
|
|
@CommandArgument("--uv-config-file", help="Update Verify config file")
|
|
@CommandArgument(
|
|
"--use-balrog-staging", action="store_true", help="Update from staging, not prod"
|
|
)
|
|
def build(command_context, binary_path, **kwargs):
|
|
config = UpdateTestConfig()
|
|
|
|
fetches = environ.get("MOZ_FETCHES_DIR")
|
|
if fetches:
|
|
config_file = Path(fetches, config.update_verify_file)
|
|
if kwargs.get("uv_config_file"):
|
|
config.config_source = "commandline"
|
|
elif config_file.is_file():
|
|
kwargs["uv_config_file"] = config_file
|
|
config.config_source = "kind_dependency"
|
|
|
|
if not kwargs.get("uv_config_file"):
|
|
config.add_update_verify_config()
|
|
else:
|
|
config.add_update_verify_config(kwargs["uv_config_file"])
|
|
# TODO: update tests to check against config version, not update server resp
|
|
# kwargs["to_display_version"] = uv_config.to_display_version
|
|
|
|
if kwargs.get("source_locale"):
|
|
config.locale = kwargs["source_locale"]
|
|
|
|
if kwargs.get("source_versions_back"):
|
|
config.source_version_position = -int(kwargs["source_versions_back"])
|
|
|
|
if kwargs.get("source_version"):
|
|
config.source_version = kwargs["source_version"]
|
|
else:
|
|
config.source_version = None
|
|
|
|
config.set_ftp_info()
|
|
|
|
tempdir = tempfile.TemporaryDirectory()
|
|
# If we have a symlink to the tmp directory, resolve it
|
|
tempdir_name = str(Path(tempdir.name).resolve())
|
|
config.tempdir = tempdir_name
|
|
test_type = kwargs.get("test_type")
|
|
|
|
if kwargs.get("use_balrog_staging"):
|
|
config.staging_update = True
|
|
|
|
# Select update channel
|
|
if kwargs.get("channel"):
|
|
config.set_channel(kwargs["channel"], kwargs.get("esr_version"))
|
|
# if (config.beta and not config.update_verify_config):
|
|
# logging.error("Non-release testing on local machines is not supported.")
|
|
# sys.exit(1)
|
|
|
|
# Run the specified test in the suite
|
|
with open(config.manifest_loc) as f:
|
|
old_content = f.read()
|
|
|
|
with open(config.manifest_loc, "w") as f:
|
|
f.write("[DEFAULT]\n\n")
|
|
if test_type.lower() == "base":
|
|
f.write('["test_apply_update.py"]')
|
|
elif test_type.lower() == "background":
|
|
f.write('["test_background_update.py"]')
|
|
else:
|
|
logging.ERROR("Invalid test type")
|
|
sys.exit(1)
|
|
|
|
config.dir = command_context.topsrcdir
|
|
|
|
if mozinfo.os == "win":
|
|
config.log_file_path = bits_pretest()
|
|
try:
|
|
kwargs["binary"] = set_up(
|
|
binary_path or get_binary_path(config, **kwargs), config
|
|
)
|
|
# TODO: change tests to check against config, not update server response
|
|
# if not kwargs.get("to_display_version"):
|
|
# kwargs["to_display_version"] = config.target_version
|
|
return run_tests(config, **kwargs)
|
|
except BinaryNotFoundException as e:
|
|
command_context.log(
|
|
logging.ERROR,
|
|
"update-test",
|
|
{"error": str(e)},
|
|
"ERROR: {error}",
|
|
)
|
|
command_context.log(logging.INFO, "update-test", {"help": e.help()}, "{help}")
|
|
return 1
|
|
finally:
|
|
with open(config.manifest_loc, "w") as f:
|
|
f.write(old_content)
|
|
if mozinfo.os == "win":
|
|
bits_posttest(config)
|
|
tempdir.cleanup()
|
|
|
|
|
|
def run_tests(config, **kwargs):
|
|
from argparse import Namespace
|
|
|
|
from marionette_harness.runtests import MarionetteHarness, MarionetteTestRunner
|
|
|
|
args = Namespace()
|
|
args.binary = kwargs["binary"]
|
|
args.logger = kwargs.pop("log", None)
|
|
if not args.logger:
|
|
args.logger = commandline.setup_logging(
|
|
"Update Tests", args, {"mach": sys.stdout}
|
|
)
|
|
|
|
for k, v in kwargs.items():
|
|
setattr(args, k, v)
|
|
|
|
args.tests = [
|
|
Path(
|
|
config.dir,
|
|
config.manifest_loc,
|
|
)
|
|
]
|
|
args.gecko_log = "-"
|
|
|
|
parser = setup_update_argument_parser()
|
|
parser.verify_usage(args)
|
|
|
|
failed = MarionetteHarness(MarionetteTestRunner, args=vars(args)).run()
|
|
if config.version_info_path:
|
|
with config.version_info_path.open("a") as fh:
|
|
fh.write(f"Status: {'failed' if failed else 'passed'}\n")
|
|
if failed > 0:
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def copy_macos_channelprefs(config) -> str:
|
|
# Copy ChannelPrefs.framework to the correct location on MacOS,
|
|
# return the location of the Fx executable
|
|
installed_app_dir = Path(config.tempdir, config.app_dir_name)
|
|
|
|
bz_channelprefs_link = "https://bugzilla.mozilla.org/attachment.cgi?id=9417387"
|
|
|
|
resp = requests.get(bz_channelprefs_link)
|
|
download_target = Path(config.tempdir, "channelprefs.zip")
|
|
unpack_target = str(download_target).rsplit(".", 1)[0]
|
|
with download_target.open("wb") as fh:
|
|
fh.write(resp.content)
|
|
|
|
unpack_archive(download_target, unpack_target)
|
|
print(
|
|
f"Downloaded channelprefs.zip to {download_target} and unpacked to {unpack_target}"
|
|
)
|
|
|
|
src = Path(config.tempdir, "channelprefs", config.channel)
|
|
dst = Path(installed_app_dir, "IceCat.app", "Contents", "Frameworks")
|
|
|
|
Path(installed_app_dir, "IceCat.app").chmod(455) # rwx for all users
|
|
|
|
print(f"Copying ChannelPrefs.framework from {src} to {dst}")
|
|
copytree(
|
|
Path(src, "ChannelPrefs.framework"),
|
|
Path(dst, "ChannelPrefs.framework"),
|
|
dirs_exist_ok=True,
|
|
)
|
|
|
|
# test against the binary that was copied to local
|
|
fx_executable = Path(
|
|
installed_app_dir, "IceCat.app", "Contents", "MacOS", "icecat"
|
|
)
|
|
return str(fx_executable)
|
|
|
|
|
|
def set_up(binary_path, config):
|
|
# Set channel prefs for all OS targets
|
|
binary_path_str = mozinstall.get_binary(binary_path, "IceCat")
|
|
print(f"Binary path: {binary_path_str}")
|
|
binary_dir = Path(binary_path_str).absolute().parent
|
|
|
|
if mozinfo.os == "mac":
|
|
return copy_macos_channelprefs(config)
|
|
else:
|
|
with Path(binary_dir, "update-settings.ini").open("w") as f:
|
|
f.write("[Settings]\n")
|
|
f.write(f"ACCEPTED_MAR_CHANNEL_IDS={config.mar_channel}")
|
|
|
|
with Path(binary_dir, "defaults", "pref", "channel-prefs.js").open("w") as f:
|
|
f.write(f'pref("app.update.channel", "{config.channel}");')
|
|
|
|
return binary_path_str
|
|
|
|
|
|
def bits_pretest():
|
|
# Check that BITS is enabled
|
|
for line in subprocess.check_output(["sc", "qc", "BITS"], text=True).split("\n"):
|
|
if "START_TYPE" in line:
|
|
assert "DISABLED" not in line
|
|
# Write all logs to a file to check for results later
|
|
log_file = tempfile.NamedTemporaryFile(mode="wt", delete=False)
|
|
sys.stdout = log_file
|
|
return log_file
|
|
|
|
|
|
def bits_posttest(config):
|
|
if config.staging_update:
|
|
# If we are in try, we didn't run the full test and BITS will fail.
|
|
return None
|
|
config.log_file_path.close()
|
|
sys.stdout = sys.__stdout__
|
|
|
|
failed = 0
|
|
try:
|
|
# Check that all the expected logs are present
|
|
downloader_regex = r"UpdateService:makeBitsRequest - Starting BITS download with url: https?:\/\/.+, updateDir: .+, filename: .+"
|
|
bits_download_regex = (
|
|
r"Downloader:downloadUpdate - BITS download running. BITS ID: {.+}"
|
|
)
|
|
|
|
with open(config.log_file_path.name, errors="ignore") as f:
|
|
logs = f.read()
|
|
assert re.search(downloader_regex, logs)
|
|
assert re.search(bits_download_regex, logs)
|
|
assert (
|
|
"AUS:SVC Downloader:_canUseBits - Not using BITS because it was already tried"
|
|
not in logs
|
|
)
|
|
assert (
|
|
"AUS:SVC Downloader:downloadUpdate - Starting nsIIncrementalDownload with url:"
|
|
not in logs
|
|
)
|
|
except (UnicodeDecodeError, AssertionError) as e:
|
|
failed = 1
|
|
logging.error(e.__traceback__)
|
|
finally:
|
|
Path(config.log_file_path.name).unlink()
|
|
|
|
if config.version_info_path:
|
|
with config.version_info_path.open("a") as fh:
|
|
fh.write(f"BITS: {'failed' if failed else 'passed'}\n")
|
|
|
|
if failed:
|
|
sys.exit(1)
|