testing/mozharness/scripts/l10n_bumper.py
author Ted Campbell <tcampbell@mozilla.com>
Sat, 18 Sep 2021 15:53:59 +0000
changeset 592430 9adcbf4e1bd9385a3128de0501859a3f144cf672
parent 560640 bc9bb47191d5411f63fe05af2534cac4ba6d90e0
permissions -rwxr-xr-x
Bug 1731434 - Fix handling of double-faults while throwing overrecursed r=arai The JSContext::generatingError re-entrancy check can generate uncatchable exceptions while throwing errors. Fix ReportOverRecursed to reflect this. Differential Revision: https://phabricator.services.mozilla.com/D126034

#!/usr/bin/env python
# ***** BEGIN LICENSE BLOCK *****
# 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/.
# ***** END LICENSE BLOCK *****
""" l10n_bumper.py

    Updates a gecko repo with up to date changesets from l10n.mozilla.org.

    Specifically, it updates l10n-changesets.json which is used by mobile releases.

    This is to allow for `mach taskgraph` to reference specific l10n revisions
    without having to resort to task.extra or commandline base64 json hacks.
"""
from __future__ import absolute_import
import codecs
import os
import pprint
import sys
import time

try:
    import simplejson as json

    assert json
except ImportError:
    import json

sys.path.insert(1, os.path.dirname(sys.path[0]))

from mozharness.base.errors import HgErrorList
from mozharness.base.vcs.vcsbase import VCSScript
from mozharness.base.log import FATAL


class L10nBumper(VCSScript):
    config_options = [
        [
            [
                "--ignore-closed-tree",
            ],
            {
                "action": "store_true",
                "dest": "ignore_closed_tree",
                "default": False,
                "help": "Bump l10n changesets on a closed tree.",
            },
        ],
        [
            [
                "--build",
            ],
            {
                "action": "store_false",
                "dest": "dontbuild",
                "default": True,
                "help": "Trigger new builds on push.",
            },
        ],
    ]

    def __init__(self, require_config_file=True):
        super(L10nBumper, self).__init__(
            all_actions=[
                "clobber",
                "check-treestatus",
                "checkout-gecko",
                "bump-changesets",
                "push",
                "push-loop",
            ],
            default_actions=[
                "push-loop",
            ],
            require_config_file=require_config_file,
            config_options=self.config_options,
            # Default config options
            config={
                "treestatus_base_url": "https://treestatus.mozilla-releng.net",
                "log_max_rotate": 99,
            },
        )

    # Helper methods {{{1
    def query_abs_dirs(self):
        if self.abs_dirs:
            return self.abs_dirs

        abs_dirs = super(L10nBumper, self).query_abs_dirs()

        abs_dirs.update(
            {
                "gecko_local_dir": os.path.join(
                    abs_dirs["abs_work_dir"],
                    self.config.get(
                        "gecko_local_dir",
                        os.path.basename(self.config["gecko_pull_url"]),
                    ),
                ),
            }
        )
        self.abs_dirs = abs_dirs
        return self.abs_dirs

    def hg_commit(self, path, repo_path, message):
        """
        Commits changes in repo_path, with specified user and commit message
        """
        user = self.config["hg_user"]
        hg = self.query_exe("hg", return_type="list")
        env = self.query_env(partial_env={"LANG": "en_US.UTF-8"})
        cmd = hg + ["add", path]
        self.run_command(cmd, cwd=repo_path, env=env)
        cmd = hg + ["commit", "-u", user, "-m", message]
        self.run_command(cmd, cwd=repo_path, env=env)

    def hg_push(self, repo_path):
        hg = self.query_exe("hg", return_type="list")
        command = hg + [
            "push",
            "-e",
            "ssh -oIdentityFile=%s -l %s"
            % (
                self.config["ssh_key"],
                self.config["ssh_user"],
            ),
            "-r",
            ".",
            self.config["gecko_push_url"],
        ]
        status = self.run_command(command, cwd=repo_path, error_list=HgErrorList)
        if status != 0:
            # We failed; get back to a known state so we can either retry
            # or fail out and continue later.
            self.run_command(
                hg
                + ["--config", "extensions.mq=", "strip", "--no-backup", "outgoing()"],
                cwd=repo_path,
            )
            self.run_command(hg + ["up", "-C"], cwd=repo_path)
            self.run_command(
                hg + ["--config", "extensions.purge=", "purge", "--all"], cwd=repo_path
            )
            return False
        return True

    def _read_json(self, path):
        contents = self.read_from_file(path)
        try:
            json_contents = json.loads(contents)
            return json_contents
        except ValueError:
            self.error("%s is invalid json!" % path)

    def _read_version(self, path):
        contents = self.read_from_file(path).split("\n")[0]
        return contents.split(".")

    def _build_locale_map(self, old_contents, new_contents):
        locale_map = {}
        for key in old_contents:
            if key not in new_contents:
                locale_map[key] = "removed"
        for k, v in new_contents.items():
            if old_contents.get(k, {}).get("revision") != v["revision"]:
                locale_map[k] = v["revision"]
            elif old_contents.get(k, {}).get("platforms") != v["platforms"]:
                locale_map[k] = v["platforms"]
        return locale_map

    def _build_platform_dict(self, bump_config):
        dirs = self.query_abs_dirs()
        repo_path = dirs["gecko_local_dir"]
        platform_dict = {}
        ignore_config = bump_config.get("ignore_config", {})
        for platform_config in bump_config["platform_configs"]:
            path = os.path.join(repo_path, platform_config["path"])
            self.info(
                "Reading %s for %s locales..." % (path, platform_config["platforms"])
            )
            contents = self.read_from_file(path)
            for locale in contents.splitlines():
                # locale is 1st word in line in shipped-locales
                if platform_config.get("format") == "shipped-locales":
                    locale = locale.split(" ")[0]
                existing_platforms = set(
                    platform_dict.get(locale, {}).get("platforms", [])
                )
                platforms = set(platform_config["platforms"])
                ignore_platforms = set(ignore_config.get(locale, []))
                platforms = (platforms | existing_platforms) - ignore_platforms
                platform_dict[locale] = {"platforms": sorted(list(platforms))}
        self.info("Built platform_dict:\n%s" % pprint.pformat(platform_dict))
        return platform_dict

    def _build_revision_dict(self, bump_config, version_list):
        self.info("Building revision dict...")
        platform_dict = self._build_platform_dict(bump_config)
        revision_dict = {}
        if bump_config.get("revision_url"):
            repl_dict = {
                "MAJOR_VERSION": version_list[0],
                "COMBINED_MAJOR_VERSION": str(
                    int(version_list[0]) + int(version_list[1])
                ),
            }

            url = bump_config["revision_url"] % repl_dict
            path = self.download_file(url, error_level=FATAL)
            revision_info = self.read_from_file(path)
            self.info("Got %s" % revision_info)
            for line in revision_info.splitlines():
                locale, revision = line.split(" ")
                if locale in platform_dict:
                    revision_dict[locale] = platform_dict[locale]
                    revision_dict[locale]["revision"] = revision
        else:
            for k, v in platform_dict.items():
                v["revision"] = "default"
                revision_dict[k] = v
        self.info("revision_dict:\n%s" % pprint.pformat(revision_dict))
        return revision_dict

    def build_commit_message(self, name, locale_map):
        comments = ""
        approval_str = "r=release a=l10n-bump"
        for locale, revision in sorted(locale_map.items()):
            comments += "%s -> %s\n" % (locale, revision)
        if self.config["dontbuild"]:
            approval_str += " DONTBUILD"
        if self.config["ignore_closed_tree"]:
            approval_str += " CLOSED TREE"
        message = "no bug - Bumping %s %s\n\n" % (name, approval_str)
        message += comments
        message = message.encode("utf-8")
        return message

    def query_treestatus(self):
        "Return True if we can land based on treestatus"
        c = self.config
        dirs = self.query_abs_dirs()
        tree = c.get(
            "treestatus_tree", os.path.basename(c["gecko_pull_url"].rstrip("/"))
        )
        treestatus_url = "%s/trees/%s" % (c["treestatus_base_url"], tree)
        treestatus_json = os.path.join(dirs["abs_work_dir"], "treestatus.json")
        if not os.path.exists(dirs["abs_work_dir"]):
            self.mkdir_p(dirs["abs_work_dir"])
        self.rmtree(treestatus_json)

        self.run_command(
            ["curl", "--retry", "4", "-o", treestatus_json, treestatus_url],
            throw_exception=True,
        )

        treestatus = self._read_json(treestatus_json)
        if treestatus["result"]["status"] != "closed":
            self.info(
                "treestatus is %s - assuming we can land"
                % repr(treestatus["result"]["status"])
            )
            return True

        return False

    # Actions {{{1
    def check_treestatus(self):
        if not self.config["ignore_closed_tree"] and not self.query_treestatus():
            self.info("breaking early since treestatus is closed")
            sys.exit(0)

    def checkout_gecko(self):
        c = self.config
        dirs = self.query_abs_dirs()
        dest = dirs["gecko_local_dir"]
        repos = [
            {
                "repo": c["gecko_pull_url"],
                "tag": c.get("gecko_tag", "default"),
                "dest": dest,
                "vcs": "hg",
            }
        ]
        self.vcs_checkout_repos(repos)

    def bump_changesets(self):
        dirs = self.query_abs_dirs()
        repo_path = dirs["gecko_local_dir"]
        version_path = os.path.join(repo_path, self.config["version_path"])
        changes = False
        version_list = self._read_version(version_path)
        for bump_config in self.config["bump_configs"]:
            path = os.path.join(repo_path, bump_config["path"])
            # For now, assume format == 'json'.  When we add desktop support,
            # we may need to add flatfile support
            if os.path.exists(path):
                old_contents = self._read_json(path)
            else:
                old_contents = {}

            new_contents = self._build_revision_dict(bump_config, version_list)

            if new_contents == old_contents:
                continue
            # super basic sanity check
            if not isinstance(new_contents, dict) or len(new_contents) < 5:
                self.error(
                    "Cowardly refusing to land a broken-seeming changesets file!"
                )
                continue

            # Write to disk
            content_string = json.dumps(
                new_contents,
                sort_keys=True,
                indent=4,
                separators=(",", ": "),
            )
            fh = codecs.open(path, encoding="utf-8", mode="w+")
            fh.write(content_string + "\n")
            fh.close()

            locale_map = self._build_locale_map(old_contents, new_contents)

            # Commit
            message = self.build_commit_message(bump_config["name"], locale_map)
            self.hg_commit(path, repo_path, message)
            changes = True
        return changes

    def push(self):
        dirs = self.query_abs_dirs()
        repo_path = dirs["gecko_local_dir"]
        return self.hg_push(repo_path)

    def push_loop(self):
        max_retries = 5
        for _ in range(max_retries):
            changed = False
            if not self.config["ignore_closed_tree"] and not self.query_treestatus():
                # Tree is closed; exit early to avoid a bunch of wasted time
                self.info("breaking early since treestatus is closed")
                break

            self.checkout_gecko()
            if self.bump_changesets():
                changed = True

            if not changed:
                # Nothing changed, we're all done
                self.info("No changes - all done")
                break

            if self.push():
                # We did it! Hurray!
                self.info("Great success!")
                break
            # If we're here, then the push failed. It also stripped any
            # outgoing commits, so we should be in a pristine state again
            # Empty our local cache of manifests so they get loaded again next
            # time through this loop. This makes sure we get fresh upstream
            # manifests, and avoids problems like bug 979080
            self.device_manifests = {}

            # Sleep before trying again
            self.info("Sleeping 60 before trying again")
            time.sleep(60)
        else:
            self.fatal("Didn't complete successfully (hit max_retries)")

        # touch status file for nagios
        dirs = self.query_abs_dirs()
        status_path = os.path.join(dirs["base_work_dir"], self.config["status_path"])
        self._touch_file(status_path)


# __main__ {{{1
if __name__ == "__main__":
    bumper = L10nBumper()
    bumper.run_and_exit()