#!/usr/bin/env python # Documentation: https://firefox-source-docs.mozilla.org/taskcluster/partner-repacks.html import json import logging import os import re import stat import sys import tarfile import urllib.parse import urllib.request import zipfile from optparse import OptionParser from pathlib import Path from shutil import copy, copytree, move, rmtree, which from subprocess import Popen from redo import retry logging.basicConfig( stream=sys.stdout, level=logging.INFO, format="%(asctime)-15s - %(levelname)s - %(message)s", ) log = logging.getLogger(__name__) # Set default values. PARTNERS_DIR = Path("..") / ".." / "workspace" / "partners" # No platform in this path because script only supports repacking a single platform at once DEFAULT_OUTPUT_DIR = "%(partner)s/%(partner_distro)s/%(locale)s" TASKCLUSTER_ARTIFACTS = ( os.environ.get("TASKCLUSTER_ROOT_URL", "https://firefox-ci-tc.services.mozilla.com") + "/api/queue/v1/task/{taskId}/artifacts" ) UPSTREAM_ENUS_PATH = "public/build/{filename}" UPSTREAM_L10N_PATH = "public/build/{locale}/{filename}" WINDOWS_DEST_DIR = Path("firefox") MAC_DEST_DIR = Path("Contents/Resources") LINUX_DEST_DIR = Path("firefox") BOUNCER_PRODUCT_TEMPLATE = ( "partner-firefox-{release_type}-{partner}-{partner_distro}-latest" ) class StrictFancyURLopener(urllib.request.FancyURLopener): """Unlike FancyURLopener this class raises exceptions for generic HTTP errors, like 404, 500. It reuses URLopener.http_error_default redefined in FancyURLopener""" def http_error_default(self, url, fp, errcode, errmsg, headers): urllib.request.URLopener.http_error_default( self, url, fp, errcode, errmsg, headers ) def rmdirRecursive(directory: Path): """ This is similar to a call of shutil.rmtree(), except that it should work better on Windows since it will more aggressively attempt to remove files marked as "read-only". """ def rmdir_including_read_only(func, path: str, exc_info): """ Source: https://stackoverflow.com/a/4829285 path contains the path of the file that couldn't be removed. Let's just assume that it's read-only and unlink it. """ path = Path(path) path.chmod(mode=stat.S_IWRITE) path.unlink() rmtree(str(directory), onerror=rmdir_including_read_only) def printSeparator(): log.info("##################################################") def shellCommand(cmd): log.debug("Executing %s" % cmd) log.debug(f"in {Path.cwd()}") # Shell command output gets dumped immediately to stdout, whereas # print statements get buffered unless we flush them explicitly. sys.stdout.flush() p = Popen(cmd, shell=True) (_, ret) = os.waitpid(p.pid, 0) if ret != 0: ret_real = (ret & 0xFF00) >> 8 log.error("Error: shellCommand had non-zero exit status: %d" % ret_real) log.error("Command: %s" % cmd, exc_info=True) sys.exit(ret_real) return True def isLinux(platform: str): return "linux" in platform def isLinux32(platform: str): return "linux32" in platform or "linux-i686" in platform or platform == "linux" def isLinux64(platform: str): return "linux64" in platform or "linux-x86_64" in platform def isMac(platform: str): return "mac" in platform def isWin(platform: str): return "win" in platform def isWin32(platform: str): return "win32" in platform def isWin64(platform: str): return platform == "win64" def isWin64Aarch64(platform: str): return platform == "win64-aarch64" def isValidPlatform(platform: str): return ( isLinux64(platform) or isLinux32(platform) or isMac(platform) or isWin64(platform) or isWin64Aarch64(platform) or isWin32(platform) ) def parseRepackConfig(file: Path, platform: str): """Did you hear about this cool file format called yaml ? json ? Yeah, me neither""" config = {} config["platforms"] = [] for line in file.open(): line = line.rstrip("\n") # Ignore empty lines if line.strip() == "": continue # Ignore comments if line.startswith("#"): continue [key, value] = line.split("=", 2) value = value.strip('"') # strings that don't need special handling if key in ("dist_id", "replacement_setup_exe"): config[key] = value continue # booleans that don't need special handling if key in ("migrationWizardDisabled", "oem", "repack_stub_installer"): if value.lower() == "true": config[key] = True continue # special cases if key == "locales": config["locales"] = value.split(" ") continue if key.startswith("locale."): config[key] = value continue if key == "deb_section": config["deb_section"] = re.sub("/", "\/", value) continue if isValidPlatform(key): ftp_platform = getFtpPlatform(key) if ftp_platform == getFtpPlatform(platform) and value.lower() == "true": config["platforms"].append(ftp_platform) continue # this only works for one locale because setup.exe is localised if config.get("replacement_setup_exe") and len(config.get("locales", [])) > 1: log.error( "Error: replacement_setup_exe is only supported for one locale, got %s" % config["locales"] ) sys.exit(1) # also only works for one platform because setup.exe is platform-specific if config["platforms"]: return config def getFtpPlatform(platform: str): """Returns the platform in the format used in building package names. Note: we rely on this code being idempotent i.e. getFtpPlatform(getFtpPlatform(foo)) should work """ if isLinux64(platform): return "linux-x86_64" if isLinux(platform): return "linux-i686" if isMac(platform): return "mac" if isWin64Aarch64(platform): return "win64-aarch64" if isWin64(platform): return "win64" if isWin32(platform): return "win32" def getFileExtension(platform: str): """The extension for the output file, which may be passed to the internal-signing task""" if isLinux(platform): return "tar.bz2" elif isMac(platform): return "tar.gz" elif isWin(platform): return "zip" def getFilename(platform: str): """Returns the filename to be repacked for the platform""" return f"target.{getFileExtension(platform)}" def getAllFilenames(platform: str, repack_stub_installer): """Returns the full list of filenames we want to downlaod for each platform""" file_names = [getFilename(platform)] if isWin(platform): # we want to copy forward setup.exe from upstream tasks to make it easier to repackage # windows installers later file_names.append("setup.exe") # Same for the stub installer with setup-stub.exe, but only in win32 repack jobs if isWin32(platform) and repack_stub_installer: file_names.append("setup-stub.exe") return tuple(file_names) def getTaskArtifacts(taskId): try: retrieveFile( TASKCLUSTER_ARTIFACTS.format(taskId=taskId), Path("tc_artifacts.json") ) tc_index = json.load(open("tc_artifacts.json")) return tc_index["artifacts"] except (ValueError, KeyError): log.error("Failed to get task artifacts from TaskCluster") raise def getUpstreamArtifacts(upstream_tasks, repack_stub_installer): useful_artifacts = getAllFilenames(options.platform, repack_stub_installer) artifact_ids = {} for taskId in upstream_tasks: for artifact in getTaskArtifacts(taskId): name = artifact["name"] if not name.endswith(useful_artifacts): continue if name in artifact_ids: log.error( "Duplicated artifact %s processing tasks %s & %s", name, taskId, artifacts[name], ) sys.exit(1) else: artifact_ids[name] = taskId log.debug( "Found artifacts: %s" % json.dumps(artifact_ids, indent=4, sort_keys=True) ) return artifact_ids def getArtifactNames(platform: str, locale, repack_stub_installer): file_names = getAllFilenames(platform, repack_stub_installer) if locale == "en-US": names = [UPSTREAM_ENUS_PATH.format(filename=f) for f in file_names] else: names = [ UPSTREAM_L10N_PATH.format(locale=locale, filename=f) for f in file_names ] return names def retrieveFile(url, file_path: Path): success = True url = urllib.parse.quote(url, safe=":/") log.info(f"Downloading from {url}") log.info(f"To: {file_path}") log.info(f"CWD: {Path.cwd()}") try: # use URLopener, which handles errors properly retry( StrictFancyURLopener().retrieve, kwargs=dict(url=url, filename=str(file_path)), ) except IOError: log.error("Error downloading %s" % url, exc_info=True) success = False try: file_path.unlink() except OSError: log.info(f"Cannot remove {file_path}", exc_info=True) return success def getBouncerProduct(partner, partner_distro): if "RELEASE_TYPE" not in os.environ: log.fatal("RELEASE_TYPE must be set in the environment") sys.exit(1) release_type = os.environ["RELEASE_TYPE"] # For X.0 releases we get 'release-rc' but the alias should use 'release' if release_type == "release-rc": release_type = "release" return BOUNCER_PRODUCT_TEMPLATE.format( release_type=release_type, partner=partner, partner_distro=partner_distro, ) class RepackBase(object): def __init__( self, build: str, partner_dir: Path, build_dir: Path, final_dir: Path, ftp_platform: str, repack_info, file_mode=0o644, quiet=False, source_locale=None, locale=None, ): self.base_dir = Path.cwd() self.build = build self.full_build_path = build_dir / build if not self.full_build_path.is_absolute(): self.full_build_path = self.base_dir / self.full_build_path self.full_partner_path = self.base_dir / partner_dir self.working_dir = final_dir / "working" self.final_dir = final_dir self.final_build = final_dir / Path(build).name self.ftp_platform = ftp_platform self.repack_info = repack_info self.file_mode = file_mode self.quiet = quiet self.source_locale = source_locale self.locale = locale self.working_dir.mkdir(mode=0o755, exist_ok=True, parents=True) def announceStart(self): log.info( "Repacking %s %s build %s" % (self.ftp_platform, self.locale, self.build) ) def announceSuccess(self): log.info( "Done repacking %s %s build %s" % (self.ftp_platform, self.locale, self.build) ) def unpackBuild(self): copy(str(self.full_build_path), ".") def createOverrideIni(self, partner_path: Path): """If this is a partner specific locale (like en-HK), set the distribution.ini to use that locale, not the default locale. """ if self.locale != self.source_locale: file_path = partner_path / "distribution" / "distribution.ini" with file_path.open(file_path.is_file() and "a" or "w") as open_file: open_file.write("[Locale]\n") open_file.write("locale=" + self.locale + "\n") """ Some partners need to override the migration wizard. This is done by adding an override.ini file to the base install dir. """ # modify distribution.ini if 44 or later and we have migrationWizardDisabled if int(options.version.split(".")[0]) >= 44: file_path = partner_path / "distribution" / "distribution.ini" with file_path.open() as open_file: ini = open_file.read() if ini.find("EnableProfileMigrator") >= 0: return else: browser_dir = partner_path / "browser" if not browser_dir.exists(): browser_dir.mkdir(mode=0o755, exist_ok=True, parents=True) file_path = browser_dir / "override.ini" if "migrationWizardDisabled" in self.repack_info: log.info("Adding EnableProfileMigrator to %r" % (file_path,)) with file_path.open(file_path.is_file() and "a" or "w") as open_file: open_file.write("[XRE]\n") open_file.write("EnableProfileMigrator=0\n") def copyFiles(self, platform_dir: Path): log.info(f"Copying files into {platform_dir}") # Check whether we've already copied files over for this partner. if not platform_dir.exists(): platform_dir.mkdir(mode=0o755, exist_ok=True, parents=True) for i in ["distribution", "extensions"]: full_path = self.full_partner_path / i if full_path.exists(): copytree(str(full_path), str(platform_dir / i)) self.createOverrideIni(platform_dir) def repackBuild(self): pass def stage(self): move(self.build, str(self.final_dir)) self.final_build.chmod(self.file_mode) def cleanup(self): self.final_build.unlink() def doRepack(self): self.announceStart() os.chdir(self.working_dir) self.unpackBuild() self.copyFiles() self.repackBuild() self.stage() os.chdir(self.base_dir) rmdirRecursive(self.working_dir) self.announceSuccess() class RepackLinux(RepackBase): def __init__( self, build: str, partner_dir: Path, build_dir: Path, final_dir: Path, ftp_platform: str, repack_info, **kwargs, ): super(RepackLinux, self).__init__( build, partner_dir, build_dir, final_dir, ftp_platform, repack_info, **kwargs, ) self.uncompressed_build = build.replace(".bz2", "") def unpackBuild(self): super(RepackLinux, self).unpackBuild() bunzip2_cmd = "bunzip2 %s" % self.build shellCommand(bunzip2_cmd) if not Path(self.uncompressed_build).exists(): log.error(f"Error: Unable to uncompress build {self.build}") sys.exit(1) def copyFiles(self): super(RepackLinux, self).copyFiles(LINUX_DEST_DIR) def repackBuild(self): if options.quiet: tar_flags = "rf" else: tar_flags = "rvf" tar_cmd = "tar %s %s %s" % (tar_flags, self.uncompressed_build, LINUX_DEST_DIR) shellCommand(tar_cmd) bzip2_command = "bzip2 %s" % self.uncompressed_build shellCommand(bzip2_command) class RepackMac(RepackBase): def __init__( self, build: str, partner_dir: Path, build_dir: Path, final_dir: Path, ftp_platform: str, repack_info, **kwargs, ): super(RepackMac, self).__init__( build, partner_dir, build_dir, final_dir, ftp_platform, repack_info, **kwargs, ) self.uncompressed_build = build.replace(".gz", "") def unpackBuild(self): super(RepackMac, self).unpackBuild() gunzip_cmd = "gunzip %s" % self.build shellCommand(gunzip_cmd) if not Path(self.uncompressed_build).exists(): log.error(f"Error: Unable to uncompress build {self.build}") sys.exit(1) self.appName = self.getAppName() def getAppName(self): # Cope with Firefox.app vs Firefox Nightly.app by returning the first root object/folder found t = tarfile.open(self.build.rsplit(".", 1)[0]) for name in t.getnames(): root_object = name.split("/")[0] if root_object.endswith(".app"): log.info(f"Found app name in tarball: {root_object}") return root_object log.error( f"Error: Unable to determine app name from tarball: {self.build} - Expected .app in root" ) sys.exit(1) def copyFiles(self): super(RepackMac, self).copyFiles(Path(self.appName) / MAC_DEST_DIR) def repackBuild(self): if options.quiet: tar_flags = "rf" else: tar_flags = "rvf" # the final arg is quoted because it may contain a space, eg Firefox Nightly.app/.... tar_cmd = "tar %s %s '%s'" % ( tar_flags, self.uncompressed_build, Path(self.appName) / MAC_DEST_DIR, ) shellCommand(tar_cmd) gzip_command = "gzip %s" % self.uncompressed_build shellCommand(gzip_command) class RepackWin(RepackBase): def __init__( self, build: str, partner_dir: Path, build_dir: Path, final_dir: Path, ftp_platform: str, repack_info, **kwargs, ): super(RepackWin, self).__init__( build, partner_dir, build_dir, final_dir, ftp_platform, repack_info, **kwargs, ) def copyFiles(self): super(RepackWin, self).copyFiles(WINDOWS_DEST_DIR) def repackBuild(self): if options.quiet: zip_flags = "-rq" else: zip_flags = "-r" zip_cmd = f"zip {zip_flags} {self.build} {WINDOWS_DEST_DIR}" shellCommand(zip_cmd) # we generate the stub installer during the win32 build, so repack it on win32 too if isWin32(options.platform) and self.repack_info.get("repack_stub_installer"): log.info("Creating target-stub.zip to hold custom urls") dest = str(self.final_build).replace("target.zip", "target-stub.zip") z = zipfile.ZipFile(dest, "w") # load the partner.ini template and interpolate %LOCALE% to the actual locale with (self.full_partner_path / "stub" / "partner.ini").open() as open_file: partner_ini_template = open_file.readlines() partner_ini = "" for l in partner_ini_template: l = l.replace("%LOCALE%", self.locale) l = l.replace("%BOUNCER_PRODUCT%", self.repack_info["bouncer_product"]) partner_ini += l z.writestr("partner.ini", partner_ini) # we need an empty firefox directory to use the repackage code d = zipfile.ZipInfo("firefox/") # https://stackoverflow.com/a/6297838, zip's representation of drwxr-xr-x permissions # is 040755 << 16L, bitwise OR with 0x10 for the MS-DOS directory flag d.external_attr = 1106051088 z.writestr(d, "") z.close() def stage(self): super(RepackWin, self).stage() setup_dest = Path(str(self.final_build).replace("target.zip", "setup.exe")) if "replacement_setup_exe" in self.repack_info: log.info("Overriding setup.exe with custom copy") retrieveFile(self.repack_info["replacement_setup_exe"], setup_dest) else: # otherwise copy forward the vanilla copy log.info("Copying vanilla setup.exe forward for installer creation") setup = str(self.full_build_path).replace("target.zip", "setup.exe") copy(setup, str(setup_dest)) setup_dest.chmod(self.file_mode) # we generate the stub installer in the win32 build, so repack it on win32 too if isWin32(options.platform) and self.repack_info.get("repack_stub_installer"): log.info( "Copying vanilla setup-stub.exe forward for stub installer creation" ) setup_dest = Path( str(self.final_build).replace("target.zip", "setup-stub.exe") ) setup_source = str(self.full_build_path).replace( "target.zip", "setup-stub.exe" ) copy(setup_source, str(setup_dest)) setup_dest.chmod(self.file_mode) if __name__ == "__main__": error = False partner_builds = {} repack_build = { "linux-i686": RepackLinux, "linux-x86_64": RepackLinux, "mac": RepackMac, "win32": RepackWin, "win64": RepackWin, "win64-aarch64": RepackWin, } parser = OptionParser(usage="usage: %prog [options]") parser.add_option( "-d", "--partners-dir", dest="partners_dir", default=str(PARTNERS_DIR), help="Specify the directory where the partner config files are found", ) parser.add_option( "-p", "--partner", dest="partner", help="Repack for a single partner, specified by name", ) parser.add_option( "-v", "--version", dest="version", help="Set the version number for repacking" ) parser.add_option( "-n", "--build-number", dest="build_number", default=1, help="Set the build number for repacking", ) parser.add_option("--platform", dest="platform", help="Set the platform to repack") parser.add_option( "--include-oem", action="store_true", dest="include_oem", default=False, help="Process partners marked as OEM (these are usually one-offs)", ) parser.add_option( "-q", "--quiet", action="store_true", dest="quiet", default=False, help="Suppress standard output from the packaging tools", ) parser.add_option( "--taskid", action="append", dest="upstream_tasks", help="Specify taskIds for upstream artifacts, using 'internal sign' tasks. Multiples " "expected, e.g. --taskid foo --taskid bar. Alternatively, use a space-separated list " "stored in UPSTREAM_TASKIDS in the environment.", ) parser.add_option( "-l", "--limit-locale", action="append", dest="limit_locales", default=[], ) (options, args) = parser.parse_args() if not options.quiet: log.setLevel(logging.DEBUG) else: log.setLevel(logging.WARNING) options.partners_dir = Path(options.partners_dir.rstrip("/")) if not options.partners_dir.is_dir(): log.error(f"Error: partners dir {options.partners_dir} is not a directory.") error = True if not options.version: log.error("Error: you must specify a version number.") error = True if not options.platform: log.error("No platform specified.") error = True if not isValidPlatform(options.platform): log.error("Invalid platform %s." % options.platform) error = True upstream_tasks = options.upstream_tasks or os.getenv("UPSTREAM_TASKIDS") if not upstream_tasks: log.error( "upstream tasks should be defined using --taskid args or " "UPSTREAM_TASKIDS in env." ) error = True for tool in ("tar", "bunzip2", "bzip2", "gunzip", "gzip", "zip"): if not which(tool): log.error(f"Error: couldn't find the {tool} executable in PATH.") error = True if error: sys.exit(1) base_workdir = Path.cwd() # Look up the artifacts available on our upstreams, but only if we need to artifact_ids = {} # Local directories for builds script_directory = Path.cwd() original_builds_dir = ( script_directory / "original_builds" / options.version / f"build{options.build_number}" ) repack_version = f"{options.version}-{options.build_number}" if os.getenv("MOZ_AUTOMATION"): # running in production repacked_builds_dir = Path("/builds/worker/artifacts") else: # local development repacked_builds_dir = script_directory / "artifacts" original_builds_dir.mkdir(mode=0o755, exist_ok=True, parents=True) repacked_builds_dir.mkdir(mode=0o755, exist_ok=True, parents=True) printSeparator() # For each partner in the partners dir # Read/check the config file # Download required builds (if not already on disk) # Perform repacks # walk the partner dirs, find valid repack.cfg configs, and load them partner_dirs = [] need_stub_installers = False for root, _, all_files in os.walk(options.partners_dir): root = root.lstrip("/") partner = root[len(str(options.partners_dir)) + 1 :].split("/")[0] partner_distro = os.path.split(root)[-1] if options.partner: if ( options.partner != partner and options.partner != partner_distro[: len(options.partner)] ): continue for file in all_files: if file == "repack.cfg": log.debug( "Found partner config: {} ['{}'] {}".format( root, "', '".join(_), file ) ) root = Path(root) repack_cfg = root / file repack_info = parseRepackConfig(repack_cfg, options.platform) if not repack_info: log.debug( "no repack_info for platform %s in %s, skipping" % (options.platform, repack_cfg) ) continue if repack_info.get("repack_stub_installer"): need_stub_installers = True repack_info["bouncer_product"] = getBouncerProduct( partner, partner_distro ) partner_dirs.append((partner, partner_distro, root, repack_info)) log.info("Retrieving artifact lists from upstream tasks") artifact_ids = getUpstreamArtifacts(upstream_tasks, need_stub_installers) if not artifact_ids: log.fatal("No upstream artifacts were found") sys.exit(1) for partner, partner_distro, full_partner_dir, repack_info in partner_dirs: log.info( "Starting repack process for partner: %s/%s" % (partner, partner_distro) ) if "oem" in repack_info and options.include_oem is False: log.info( "Skipping partner: %s - marked as OEM and --include-oem was not set" % partner ) continue repack_stub_installer = repack_info.get("repack_stub_installer") # where everything ends up partner_repack_dir = repacked_builds_dir / DEFAULT_OUTPUT_DIR # Figure out which base builds we need to repack. for locale in repack_info["locales"]: if options.limit_locales and locale not in options.limit_locales: log.info("Skipping %s because it is not in limit_locales list", locale) continue source_locale = locale # Partner has specified a different locale to # use as the base for their custom locale. if "locale." + locale in repack_info: source_locale = repack_info["locale." + locale] for platform in repack_info["platforms"]: # ja-JP-mac only exists for Mac, so skip non-existent # platform/locale combos. if (source_locale == "ja" and isMac(platform)) or ( source_locale == "ja-JP-mac" and not isMac(platform) ): continue ftp_platform = getFtpPlatform(platform) local_filepath = original_builds_dir / ftp_platform / locale local_filepath.mkdir(mode=0o755, exist_ok=True, parents=True) final_dir = Path( str(partner_repack_dir) % dict( partner=partner, partner_distro=partner_distro, locale=locale, ) ) if final_dir.exists(): rmdirRecursive(final_dir) final_dir.mkdir(mode=0o755, exist_ok=True, parents=True) # for the main repacking artifact file_name = getFilename(ftp_platform) local_filename = local_filepath / file_name # Check to see if this build is already on disk, i.e. # has already been downloaded. artifacts = getArtifactNames(platform, locale, repack_stub_installer) for artifact in artifacts: local_artifact = local_filepath / Path(artifact).name if local_artifact.exists(): log.info(f"Found {local_artifact} on disk, not downloading") continue if artifact not in artifact_ids: log.fatal( "Can't determine what taskID to retrieve %s from", artifact ) sys.exit(1) original_build_url = "%s/%s" % ( TASKCLUSTER_ARTIFACTS.format(taskId=artifact_ids[artifact]), artifact, ) retrieveFile(original_build_url, local_artifact) # Make sure we have the local file now if not local_filename.exists(): log.info(f"Error: Unable to retrieve {file_name}\n") sys.exit(1) repackObj = repack_build[ftp_platform]( file_name, full_partner_dir, local_filepath, final_dir, ftp_platform, repack_info, locale=locale, source_locale=source_locale, ) repackObj.doRepack()