summaryrefslogtreecommitdiffstats
path: root/taskcluster/taskgraph/util
diff options
context:
space:
mode:
Diffstat (limited to 'taskcluster/taskgraph/util')
-rw-r--r--taskcluster/taskgraph/util/__init__.py0
-rw-r--r--taskcluster/taskgraph/util/attributes.py163
-rw-r--r--taskcluster/taskgraph/util/backstop.py81
-rw-r--r--taskcluster/taskgraph/util/bugbug.py126
-rw-r--r--taskcluster/taskgraph/util/cached_tasks.py82
-rw-r--r--taskcluster/taskgraph/util/chunking.py270
-rw-r--r--taskcluster/taskgraph/util/declarative_artifacts.py70
-rw-r--r--taskcluster/taskgraph/util/docker.py356
-rw-r--r--taskcluster/taskgraph/util/hash.py58
-rw-r--r--taskcluster/taskgraph/util/hg.py134
-rw-r--r--taskcluster/taskgraph/util/keyed_by.py89
-rw-r--r--taskcluster/taskgraph/util/parameterization.py107
-rw-r--r--taskcluster/taskgraph/util/partials.py301
-rw-r--r--taskcluster/taskgraph/util/partners.py557
-rw-r--r--taskcluster/taskgraph/util/perfile.py103
-rw-r--r--taskcluster/taskgraph/util/platforms.py61
-rw-r--r--taskcluster/taskgraph/util/python_path.py56
-rw-r--r--taskcluster/taskgraph/util/schema.py228
-rw-r--r--taskcluster/taskgraph/util/scriptworker.py819
-rw-r--r--taskcluster/taskgraph/util/signed_artifacts.py196
-rw-r--r--taskcluster/taskgraph/util/taskcluster.py373
-rw-r--r--taskcluster/taskgraph/util/taskgraph.py58
-rw-r--r--taskcluster/taskgraph/util/templates.py60
-rw-r--r--taskcluster/taskgraph/util/time.py118
-rw-r--r--taskcluster/taskgraph/util/treeherder.py67
-rw-r--r--taskcluster/taskgraph/util/verify.py454
-rw-r--r--taskcluster/taskgraph/util/workertypes.py95
-rw-r--r--taskcluster/taskgraph/util/yaml.py37
28 files changed, 5119 insertions, 0 deletions
diff --git a/taskcluster/taskgraph/util/__init__.py b/taskcluster/taskgraph/util/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/taskcluster/taskgraph/util/__init__.py
diff --git a/taskcluster/taskgraph/util/attributes.py b/taskcluster/taskgraph/util/attributes.py
new file mode 100644
index 0000000000..6a446d6fa7
--- /dev/null
+++ b/taskcluster/taskgraph/util/attributes.py
@@ -0,0 +1,163 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import re
+
+import six
+
+
+INTEGRATION_PROJECTS = {
+ "autoland",
+}
+
+TRUNK_PROJECTS = INTEGRATION_PROJECTS | {"mozilla-central", "comm-central"}
+
+RELEASE_PROJECTS = {
+ "mozilla-central",
+ "mozilla-beta",
+ "mozilla-release",
+ "mozilla-esr78",
+ "comm-central",
+ "comm-beta",
+ "comm-esr78",
+ "oak",
+}
+
+RELEASE_PROMOTION_PROJECTS = {
+ "jamun",
+ "maple",
+ "try",
+ "try-comm-central",
+} | RELEASE_PROJECTS
+
+TEMPORARY_PROJECTS = set(
+ {
+ # When using a "Disposeabel Project Branch" you can specify your branch here. e.g.:
+ # 'oak',
+ }
+)
+
+ALL_PROJECTS = RELEASE_PROMOTION_PROJECTS | TRUNK_PROJECTS | TEMPORARY_PROJECTS
+
+RUN_ON_PROJECT_ALIASES = {
+ # key is alias, value is lambda to test it against
+ "all": lambda project: True,
+ "integration": lambda project: project in INTEGRATION_PROJECTS,
+ "release": lambda project: project in RELEASE_PROJECTS,
+ "trunk": lambda project: project in TRUNK_PROJECTS,
+}
+
+_COPYABLE_ATTRIBUTES = (
+ "accepted-mar-channel-ids",
+ "artifact_map",
+ "artifact_prefix",
+ "build_platform",
+ "build_type",
+ "l10n_chunk",
+ "locale",
+ "mar-channel-id",
+ "nightly",
+ "required_signoffs",
+ "shippable",
+ "shipping_phase",
+ "shipping_product",
+ "signed",
+ "stub-installer",
+ "update-channel",
+)
+
+
+def attrmatch(attributes, **kwargs):
+ """Determine whether the given set of task attributes matches. The
+ conditions are given as keyword arguments, where each keyword names an
+ attribute. The keyword value can be a literal, a set, or a callable. A
+ literal must match the attribute exactly. Given a set, the attribute value
+ must be in the set. A callable is called with the attribute value. If an
+ attribute is specified as a keyword argument but not present in the
+ attributes, the result is False."""
+ for kwkey, kwval in six.iteritems(kwargs):
+ if kwkey not in attributes:
+ return False
+ attval = attributes[kwkey]
+ if isinstance(kwval, set):
+ if attval not in kwval:
+ return False
+ elif callable(kwval):
+ if not kwval(attval):
+ return False
+ elif kwval != attributes[kwkey]:
+ return False
+ return True
+
+
+def keymatch(attributes, target):
+ """Determine if any keys in attributes are a match to target, then return
+ a list of matching values. First exact matches will be checked. Failing
+ that, regex matches and finally a default key.
+ """
+ # exact match
+ if target in attributes:
+ return [attributes[target]]
+
+ # regular expression match
+ matches = [v for k, v in six.iteritems(attributes) if re.match(k + "$", target)]
+ if matches:
+ return matches
+
+ # default
+ if "default" in attributes:
+ return [attributes["default"]]
+
+ return []
+
+
+def match_run_on_projects(project, run_on_projects):
+ """Determine whether the given project is included in the `run-on-projects`
+ parameter, applying expansions for things like "integration" mentioned in
+ the attribute documentation."""
+ aliases = RUN_ON_PROJECT_ALIASES.keys()
+ run_aliases = set(aliases) & set(run_on_projects)
+ if run_aliases:
+ if any(RUN_ON_PROJECT_ALIASES[alias](project) for alias in run_aliases):
+ return True
+
+ return project in run_on_projects
+
+
+def match_run_on_hg_branches(hg_branch, run_on_hg_branches):
+ """Determine whether the given project is included in the `run-on-hg-branches`
+ parameter. Allows 'all'."""
+ if "all" in run_on_hg_branches:
+ return True
+
+ for expected_hg_branch_pattern in run_on_hg_branches:
+ if re.match(expected_hg_branch_pattern, hg_branch):
+ return True
+
+ return False
+
+
+def copy_attributes_from_dependent_job(dep_job, denylist=()):
+ return {
+ attr: dep_job.attributes[attr]
+ for attr in _COPYABLE_ATTRIBUTES
+ if attr in dep_job.attributes and attr not in denylist
+ }
+
+
+def sorted_unique_list(*args):
+ """Join one or more lists, and return a sorted list of unique members"""
+ combined = set().union(*args)
+ return sorted(combined)
+
+
+def release_level(project):
+ """
+ Whether this is a staging release or not.
+
+ :return six.text_type: One of "production" or "staging".
+ """
+ return "production" if project in RELEASE_PROJECTS else "staging"
diff --git a/taskcluster/taskgraph/util/backstop.py b/taskcluster/taskgraph/util/backstop.py
new file mode 100644
index 0000000000..e89cd53ef0
--- /dev/null
+++ b/taskcluster/taskgraph/util/backstop.py
@@ -0,0 +1,81 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+from requests import HTTPError
+
+from taskgraph.util.taskcluster import (
+ find_task_id,
+ get_artifact,
+ status_task,
+)
+
+
+BACKSTOP_PUSH_INTERVAL = 20
+BACKSTOP_TIME_INTERVAL = 60 * 4 # minutes
+BACKSTOP_INDEX = "gecko.v2.{project}.latest.taskgraph.backstop"
+
+
+def is_backstop(
+ params, push_interval=BACKSTOP_PUSH_INTERVAL, time_interval=BACKSTOP_TIME_INTERVAL
+):
+ """Determines whether the given parameters represent a backstop push.
+
+ Args:
+ push_interval (int): Number of pushes
+ time_interval (int): Minutes between forced schedules.
+ Use 0 to disable.
+ Returns:
+ bool: True if this is a backtop, otherwise False.
+ """
+ # In case this is being faked on try.
+ if params.get("backstop", False):
+ return True
+
+ project = params["project"]
+ pushid = int(params["pushlog_id"])
+ pushdate = int(params["pushdate"])
+
+ if project == "try":
+ return False
+ elif project != "autoland":
+ return True
+
+ # On every Nth push, want to run all tasks.
+ if pushid % push_interval == 0:
+ return True
+
+ if time_interval <= 0:
+ return False
+
+ # We also want to ensure we run all tasks at least once per N minutes.
+ index = BACKSTOP_INDEX.format(project=project)
+
+ try:
+ last_backstop_id = find_task_id(index)
+ except KeyError:
+ # Index wasn't found, implying there hasn't been a backstop push yet.
+ return True
+
+ if status_task(last_backstop_id) in ("failed", "exception"):
+ # If the last backstop failed its decision task, make this a backstop.
+ return True
+
+ try:
+ last_pushdate = get_artifact(last_backstop_id, "public/parameters.yml")[
+ "pushdate"
+ ]
+ except HTTPError as e:
+ # If the last backstop decision task exists in the index, but
+ # parameters.yml isn't available yet, it means the decision task is
+ # still running. If that's the case, we can be pretty sure the time
+ # component will not cause a backstop, so just return False.
+ if e.response.status_code == 404:
+ return False
+ raise
+
+ if (pushdate - last_pushdate) / 60 >= time_interval:
+ return True
+ return False
diff --git a/taskcluster/taskgraph/util/bugbug.py b/taskcluster/taskgraph/util/bugbug.py
new file mode 100644
index 0000000000..8fc3c07610
--- /dev/null
+++ b/taskcluster/taskgraph/util/bugbug.py
@@ -0,0 +1,126 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import os
+import json
+import sys
+import time
+
+import requests
+from mozbuild.util import memoize
+
+from taskgraph.util.taskcluster import requests_retry_session
+
+try:
+ # TODO(py3): use time.monotonic()
+ from time import monotonic
+except ImportError:
+ from time import time as monotonic
+
+BUGBUG_BASE_URL = "https://bugbug.herokuapp.com"
+RETRY_TIMEOUT = 9 * 60 # seconds
+RETRY_INTERVAL = 10 # seconds
+
+# Preset confidence thresholds.
+CT_LOW = 0.7
+CT_MEDIUM = 0.8
+CT_HIGH = 0.9
+
+GROUP_TRANSLATIONS = {
+ "testing/web-platform/tests": "",
+ "testing/web-platform/mozilla/tests": "/_mozilla",
+}
+
+
+def translate_group(group):
+ for prefix, value in GROUP_TRANSLATIONS.items():
+ if group.startswith(prefix):
+ return group.replace(prefix, value)
+
+ return group
+
+
+class BugbugTimeoutException(Exception):
+ pass
+
+
+@memoize
+def get_session():
+ s = requests.Session()
+ s.headers.update({"X-API-KEY": "gecko-taskgraph"})
+ return requests_retry_session(retries=5, session=s)
+
+
+def _write_perfherder_data(lower_is_better):
+ if os.environ.get("MOZ_AUTOMATION", "0") == "1":
+ perfherder_data = {
+ "framework": {"name": "build_metrics"},
+ "suites": [
+ {
+ "name": suite,
+ "value": value,
+ "lowerIsBetter": True,
+ "shouldAlert": False,
+ "subtests": [],
+ }
+ for suite, value in lower_is_better.items()
+ ],
+ }
+ print(
+ "PERFHERDER_DATA: {}".format(json.dumps(perfherder_data)), file=sys.stderr
+ )
+
+
+@memoize
+def push_schedules(branch, rev):
+ url = BUGBUG_BASE_URL + "/push/{branch}/{rev}/schedules".format(
+ branch=branch, rev=rev
+ )
+ start = monotonic()
+ session = get_session()
+
+ # On try there is no fallback and pulling is slower, so we allow bugbug more
+ # time to compute the results.
+ # See https://github.com/mozilla/bugbug/issues/1673.
+ timeout = RETRY_TIMEOUT
+ if branch == "try":
+ timeout += int(timeout / 3)
+
+ attempts = timeout / RETRY_INTERVAL
+ i = 0
+ while i < attempts:
+ r = session.get(url)
+ r.raise_for_status()
+
+ if r.status_code != 202:
+ break
+
+ time.sleep(RETRY_INTERVAL)
+ i += 1
+ end = monotonic()
+
+ _write_perfherder_data(
+ lower_is_better={
+ "bugbug_push_schedules_time": end - start,
+ "bugbug_push_schedules_retries": i,
+ }
+ )
+
+ data = r.json()
+ if r.status_code == 202:
+ raise BugbugTimeoutException(
+ "Timed out waiting for result from '{}'".format(url)
+ )
+
+ if "groups" in data:
+ data["groups"] = {translate_group(k): v for k, v in data["groups"].items()}
+
+ if "config_groups" in data:
+ data["config_groups"] = {
+ translate_group(k): v for k, v in data["config_groups"].items()
+ }
+
+ return data
diff --git a/taskcluster/taskgraph/util/cached_tasks.py b/taskcluster/taskgraph/util/cached_tasks.py
new file mode 100644
index 0000000000..980ef0ef79
--- /dev/null
+++ b/taskcluster/taskgraph/util/cached_tasks.py
@@ -0,0 +1,82 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import hashlib
+import time
+
+import six
+
+
+TARGET_CACHE_INDEX = "{trust_domain}.cache.level-{level}.{type}.{name}.hash.{digest}"
+EXTRA_CACHE_INDEXES = [
+ "{trust_domain}.cache.level-{level}.{type}.{name}.latest",
+ "{trust_domain}.cache.level-{level}.{type}.{name}.pushdate.{build_date_long}",
+]
+
+
+def add_optimization(
+ config, taskdesc, cache_type, cache_name, digest=None, digest_data=None
+):
+ """
+ Allow the results of this task to be cached. This adds index routes to the
+ task so it can be looked up for future runs, and optimization hints so that
+ cached artifacts can be found. Exactly one of `digest` and `digest_data`
+ must be passed.
+
+ :param TransformConfig config: The configuration for the kind being transformed.
+ :param dict taskdesc: The description of the current task.
+ :param str cache_type: The type of task result being cached.
+ :param str cache_name: The name of the object being cached.
+ :param digest: A unique string indentifying this version of the artifacts
+ being generated. Typically this will be the hash of inputs to the task.
+ :type digest: bytes or None
+ :param digest_data: A list of bytes representing the inputs of this task.
+ They will be concatenated and hashed to create the digest for this
+ task.
+ :type digest_data: list of bytes or None
+ """
+ if (digest is None) == (digest_data is None):
+ raise Exception("Must pass exactly one of `digest` and `digest_data`.")
+ if digest is None:
+ digest = hashlib.sha256(six.ensure_binary("\n".join(digest_data))).hexdigest()
+
+ subs = {
+ "trust_domain": config.graph_config["trust-domain"],
+ "type": cache_type,
+ "name": cache_name,
+ "digest": digest,
+ }
+
+ # We'll try to find a cached version of the toolchain at levels above
+ # and including the current level, starting at the highest level.
+ index_routes = []
+ for level in reversed(range(int(config.params["level"]), 4)):
+ subs["level"] = level
+ index_routes.append(TARGET_CACHE_INDEX.format(**subs))
+ taskdesc["optimization"] = {"index-search": index_routes}
+
+ # ... and cache at the lowest level.
+ taskdesc.setdefault("routes", []).append(
+ "index.{}".format(TARGET_CACHE_INDEX.format(**subs))
+ )
+
+ # ... and add some extra routes for humans
+ subs["build_date_long"] = time.strftime(
+ "%Y.%m.%d.%Y%m%d%H%M%S", time.gmtime(config.params["build_date"])
+ )
+ taskdesc["routes"].extend(
+ ["index.{}".format(route.format(**subs)) for route in EXTRA_CACHE_INDEXES]
+ )
+
+ taskdesc["attributes"]["cached_task"] = {
+ "type": cache_type,
+ "name": cache_name,
+ "digest": digest,
+ }
+
+ # Allow future pushes to find this task before it completes
+ # Implementation in morphs
+ taskdesc["attributes"]["eager_indexes"] = [TARGET_CACHE_INDEX.format(**subs)]
diff --git a/taskcluster/taskgraph/util/chunking.py b/taskcluster/taskgraph/util/chunking.py
new file mode 100644
index 0000000000..ecf8098f01
--- /dev/null
+++ b/taskcluster/taskgraph/util/chunking.py
@@ -0,0 +1,270 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+"""Utility functions to handle test chunking."""
+
+import json
+import logging
+import os
+from abc import ABCMeta, abstractmethod
+
+import six
+from manifestparser import TestManifest
+from manifestparser.filters import chunk_by_runtime
+from mozbuild.util import memoize
+from moztest.resolve import (
+ TEST_SUITES,
+ TestResolver,
+ TestManifestLoader,
+)
+
+from taskgraph import GECKO
+from taskgraph.util.bugbug import BugbugTimeoutException, push_schedules
+
+logger = logging.getLogger(__name__)
+here = os.path.abspath(os.path.dirname(__file__))
+resolver = TestResolver.from_environment(cwd=here, loader_cls=TestManifestLoader)
+
+
+def guess_mozinfo_from_task(task):
+ """Attempt to build a mozinfo dict from a task definition.
+
+ This won't be perfect and many values used in the manifests will be missing. But
+ it should cover most of the major ones and be "good enough" for chunking in the
+ taskgraph.
+
+ Args:
+ task (dict): A task definition.
+
+ Returns:
+ A dict that can be used as a mozinfo replacement.
+ """
+ info = {
+ "asan": "asan" in task["build-attributes"]["build_platform"],
+ "bits": 32 if "32" in task["build-attributes"]["build_platform"] else 64,
+ "ccov": "ccov" in task["build-attributes"]["build_platform"],
+ "debug": task["build-attributes"]["build_type"] == "debug",
+ "e10s": task["attributes"]["e10s"],
+ "fission": task["attributes"].get("unittest_variant") == "fission",
+ "headless": "-headless" in task["test-name"],
+ "tsan": "tsan" in task["build-attributes"]["build_platform"],
+ "webrender": task.get("webrender", False),
+ }
+ for platform in ("android", "linux", "mac", "win"):
+ if platform in task["build-attributes"]["build_platform"]:
+ info["os"] = platform
+ break
+ else:
+ raise ValueError(
+ "{} is not a known platform!".format(
+ task["build-attributes"]["build_platform"]
+ )
+ )
+
+ info["appname"] = "fennec" if info["os"] == "android" else "firefox"
+
+ # guess processor
+ if "aarch64" in task["build-attributes"]["build_platform"]:
+ info["processor"] = "aarch64"
+ elif info["os"] == "android" and "arm" in task["test-platform"]:
+ info["processor"] = "arm"
+ elif info["bits"] == 32:
+ info["processor"] = "x86"
+ else:
+ info["processor"] = "x86_64"
+
+ # guess toolkit
+ if info["os"] == "android":
+ info["toolkit"] = "android"
+ elif info["os"] == "win":
+ info["toolkit"] = "windows"
+ elif info["os"] == "mac":
+ info["toolkit"] = "cocoa"
+ else:
+ info["toolkit"] = "gtk"
+
+ return info
+
+
+@memoize
+def get_runtimes(platform, suite_name):
+ if not suite_name or not platform:
+ raise TypeError("suite_name and platform cannot be empty.")
+
+ base = os.path.join(GECKO, "testing", "runtimes", "manifest-runtimes-{}.json")
+ for key in ("android", "windows"):
+ if key in platform:
+ path = base.format(key)
+ break
+ else:
+ path = base.format("unix")
+
+ if not os.path.exists(path):
+ raise IOError("manifest runtime file at {} not found.".format(path))
+
+ with open(path, "r") as fh:
+ return json.load(fh)[suite_name]
+
+
+def chunk_manifests(suite, platform, chunks, manifests):
+ """Run the chunking algorithm.
+
+ Args:
+ platform (str): Platform used to find runtime info.
+ chunks (int): Number of chunks to split manifests into.
+ manifests(list): Manifests to chunk.
+
+ Returns:
+ A list of length `chunks` where each item contains a list of manifests
+ that run in that chunk.
+ """
+ manifests = set(manifests)
+
+ if "web-platform-tests" not in suite:
+ runtimes = {
+ k: v for k, v in get_runtimes(platform, suite).items() if k in manifests
+ }
+ return [
+ c[1]
+ for c in chunk_by_runtime(None, chunks, runtimes).get_chunked_manifests(
+ manifests
+ )
+ ]
+
+ # Keep track of test paths for each chunk, and the runtime information.
+ chunked_manifests = [[] for _ in range(chunks)]
+
+ # Spread out the test manifests evenly across all chunks.
+ for index, key in enumerate(sorted(manifests)):
+ chunked_manifests[index % chunks].append(key)
+
+ # One last sort by the number of manifests. Chunk size should be more or less
+ # equal in size.
+ chunked_manifests.sort(key=lambda x: len(x))
+
+ # Return just the chunked test paths.
+ return chunked_manifests
+
+
+@six.add_metaclass(ABCMeta)
+class BaseManifestLoader(object):
+ def __init__(self, params):
+ self.params = params
+
+ @abstractmethod
+ def get_manifests(self, flavor, subsuite, mozinfo):
+ """Compute which manifests should run for the given flavor, subsuite and mozinfo.
+
+ This function returns skipped manifests separately so that more balanced
+ chunks can be achieved by only considering "active" manifests in the
+ chunking algorithm.
+
+ Args:
+ flavor (str): The suite to run. Values are defined by the 'build_flavor' key
+ in `moztest.resolve.TEST_SUITES`.
+ subsuite (str): The subsuite to run or 'undefined' to denote no subsuite.
+ mozinfo (frozenset): Set of data in the form of (<key>, <value>) used
+ for filtering.
+
+ Returns:
+ A tuple of two manifest lists. The first is the set of active manifests (will
+ run at least one test. The second is a list of skipped manifests (all tests are
+ skipped).
+ """
+ pass
+
+
+class DefaultLoader(BaseManifestLoader):
+ """Load manifests using metadata from the TestResolver."""
+
+ @memoize
+ def get_tests(self, suite):
+ suite_definition = TEST_SUITES[suite]
+ return list(
+ resolver.resolve_tests(
+ flavor=suite_definition["build_flavor"],
+ subsuite=suite_definition.get("kwargs", {}).get(
+ "subsuite", "undefined"
+ ),
+ )
+ )
+
+ @memoize
+ def get_manifests(self, suite, mozinfo):
+ mozinfo = dict(mozinfo)
+ # Compute all tests for the given suite/subsuite.
+ tests = self.get_tests(suite)
+
+ if "web-platform-tests" in suite:
+ manifests = set()
+ for t in tests:
+ manifests.add(t["manifest"])
+ return {"active": list(manifests), "skipped": []}
+
+ manifests = set(chunk_by_runtime.get_manifest(t) for t in tests)
+
+ # Compute the active tests.
+ m = TestManifest()
+ m.tests = tests
+ tests = m.active_tests(disabled=False, exists=False, **mozinfo)
+ active = set(chunk_by_runtime.get_manifest(t) for t in tests)
+ skipped = manifests - active
+ return {"active": list(active), "skipped": list(skipped)}
+
+
+class BugbugLoader(DefaultLoader):
+ """Load manifests using metadata from the TestResolver, and then
+ filter them based on a query to bugbug."""
+
+ CONFIDENCE_THRESHOLD = 0.5
+
+ def __init__(self, *args, **kwargs):
+ super(BugbugLoader, self).__init__(*args, **kwargs)
+ self.timedout = False
+
+ @memoize
+ def get_manifests(self, suite, mozinfo):
+ manifests = super(BugbugLoader, self).get_manifests(suite, mozinfo)
+
+ # Don't prune any manifests if we're on a backstop push or there was a timeout.
+ if self.params["backstop"] or self.timedout:
+ return manifests
+
+ try:
+ data = push_schedules(self.params["project"], self.params["head_rev"])
+ except BugbugTimeoutException:
+ logger.warning("Timed out waiting for bugbug, loading all test manifests.")
+ self.timedout = True
+ return self.get_manifests(suite, mozinfo)
+
+ bugbug_manifests = {
+ m
+ for m, c in data.get("groups", {}).items()
+ if c >= self.CONFIDENCE_THRESHOLD
+ }
+
+ manifests["active"] = list(set(manifests["active"]) & bugbug_manifests)
+ manifests["skipped"] = list(set(manifests["skipped"]) & bugbug_manifests)
+ return manifests
+
+
+manifest_loaders = {
+ "bugbug": BugbugLoader,
+ "default": DefaultLoader,
+}
+
+_loader_cache = {}
+
+
+def get_manifest_loader(name, params):
+ # Ensure we never create more than one instance of the same loader type for
+ # performance reasons.
+ if name in _loader_cache:
+ return _loader_cache[name]
+
+ loader = manifest_loaders[name](dict(params))
+ _loader_cache[name] = loader
+ return loader
diff --git a/taskcluster/taskgraph/util/declarative_artifacts.py b/taskcluster/taskgraph/util/declarative_artifacts.py
new file mode 100644
index 0000000000..821200e71d
--- /dev/null
+++ b/taskcluster/taskgraph/util/declarative_artifacts.py
@@ -0,0 +1,70 @@
+# 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/.
+
+from __future__ import absolute_import, unicode_literals
+
+import re
+
+from taskgraph.util.scriptworker import generate_beetmover_upstream_artifacts
+
+
+_ARTIFACT_ID_PER_PLATFORM = {
+ "android-aarch64-opt": "geckoview-default-arm64-v8a",
+ "android-api-16-opt": "geckoview-default-armeabi-v7a",
+ "android-x86-opt": "geckoview-default-x86",
+ "android-x86_64-opt": "geckoview-default-x86_64",
+ "android-geckoview-fat-aar-opt": "geckoview-default",
+ "android-aarch64-shippable": "geckoview{update_channel}-arm64-v8a",
+ "android-api-16-shippable": "geckoview{update_channel}-armeabi-v7a",
+ "android-x86-shippable": "geckoview{update_channel}-x86",
+ "android-x86_64-shippable": "geckoview{update_channel}-x86_64",
+ "android-geckoview-fat-aar-shippable": "geckoview{update_channel}",
+}
+
+
+def get_geckoview_upstream_artifacts(config, job, platform=""):
+ if not platform:
+ platform = job["attributes"]["build_platform"]
+ upstream_artifacts = generate_beetmover_upstream_artifacts(
+ config,
+ job,
+ platform="",
+ **get_geckoview_template_vars(
+ config, platform, job["attributes"].get("update-channel")
+ )
+ )
+ return [
+ {key: value for key, value in upstream_artifact.items() if key != "locale"}
+ for upstream_artifact in upstream_artifacts
+ ]
+
+
+def get_geckoview_template_vars(config, platform, update_channel):
+ version_groups = re.match(r"(\d+).(\d+).*", config.params["version"])
+ if version_groups:
+ major_version, minor_version = version_groups.groups()
+
+ return {
+ "artifact_id": get_geckoview_artifact_id(
+ config,
+ platform,
+ update_channel,
+ ),
+ "build_date": config.params["moz_build_date"],
+ "major_version": major_version,
+ "minor_version": minor_version,
+ }
+
+
+def get_geckoview_artifact_id(config, platform, update_channel=None):
+ if update_channel == "release":
+ update_channel = ""
+ elif update_channel is not None:
+ update_channel = "-{}".format(update_channel)
+ else:
+ # For shippable builds, mozharness defaults to using
+ # "nightly-{project}" for the update channel. For other builds, the
+ # update channel is not set, but the value is not substituted.
+ update_channel = "-nightly-{}".format(config.params["project"])
+ return _ARTIFACT_ID_PER_PLATFORM[platform].format(update_channel=update_channel)
diff --git a/taskcluster/taskgraph/util/docker.py b/taskcluster/taskgraph/util/docker.py
new file mode 100644
index 0000000000..0642e4faff
--- /dev/null
+++ b/taskcluster/taskgraph/util/docker.py
@@ -0,0 +1,356 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import hashlib
+import io
+import json
+import os
+import re
+import requests
+import requests_unixsocket
+import six
+import sys
+
+from six.moves.urllib.parse import quote, urlencode, urlunparse
+from six.moves.collections_abc import Mapping
+
+from mozbuild.util import memoize
+from mozpack.files import GeneratedFile
+from mozpack.archive import create_tar_gz_from_files
+from .. import GECKO
+
+from .yaml import load_yaml
+
+
+IMAGE_DIR = os.path.join(GECKO, "taskcluster", "docker")
+
+
+def docker_url(path, **kwargs):
+ docker_socket = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
+ return urlunparse(
+ ("http+unix", quote(docker_socket, safe=""), path, "", urlencode(kwargs), "")
+ )
+
+
+def post_to_docker(tar, api_path, **kwargs):
+ """POSTs a tar file to a given docker API path.
+
+ The tar argument can be anything that can be passed to requests.post()
+ as data (e.g. iterator or file object).
+ The extra keyword arguments are passed as arguments to the docker API.
+ """
+ # requests-unixsocket doesn't honor requests timeouts
+ # See https://github.com/msabramo/requests-unixsocket/issues/44
+ # We have some large docker images that trigger the default timeout,
+ # so we increase the requests-unixsocket timeout here.
+ session = requests.Session()
+ session.mount(
+ requests_unixsocket.DEFAULT_SCHEME,
+ requests_unixsocket.UnixAdapter(timeout=120),
+ )
+ req = session.post(
+ docker_url(api_path, **kwargs),
+ data=tar,
+ stream=True,
+ headers={"Content-Type": "application/x-tar"},
+ )
+ if req.status_code != 200:
+ message = req.json().get("message")
+ if not message:
+ message = "docker API returned HTTP code {}".format(req.status_code)
+ raise Exception(message)
+ status_line = {}
+
+ buf = b""
+ for content in req.iter_content(chunk_size=None):
+ if not content:
+ continue
+ # Sometimes, a chunk of content is not a complete json, so we cumulate
+ # with leftovers from previous iterations.
+ buf += content
+ try:
+ data = json.loads(buf)
+ except Exception:
+ continue
+ buf = b""
+ # data is sometimes an empty dict.
+ if not data:
+ continue
+ # Mimick how docker itself presents the output. This code was tested
+ # with API version 1.18 and 1.26.
+ if "status" in data:
+ if "id" in data:
+ if sys.stderr.isatty():
+ total_lines = len(status_line)
+ line = status_line.setdefault(data["id"], total_lines)
+ n = total_lines - line
+ if n > 0:
+ # Move the cursor up n lines.
+ sys.stderr.write("\033[{}A".format(n))
+ # Clear line and move the cursor to the beginning of it.
+ sys.stderr.write("\033[2K\r")
+ sys.stderr.write(
+ "{}: {} {}\n".format(
+ data["id"], data["status"], data.get("progress", "")
+ )
+ )
+ if n > 1:
+ # Move the cursor down n - 1 lines, which, considering
+ # the carriage return on the last write, gets us back
+ # where we started.
+ sys.stderr.write("\033[{}B".format(n - 1))
+ else:
+ status = status_line.get(data["id"])
+ # Only print status changes.
+ if status != data["status"]:
+ sys.stderr.write("{}: {}\n".format(data["id"], data["status"]))
+ status_line[data["id"]] = data["status"]
+ else:
+ status_line = {}
+ sys.stderr.write("{}\n".format(data["status"]))
+ elif "stream" in data:
+ sys.stderr.write(data["stream"])
+ elif "aux" in data:
+ sys.stderr.write(repr(data["aux"]))
+ elif "error" in data:
+ sys.stderr.write("{}\n".format(data["error"]))
+ # Sadly, docker doesn't give more than a plain string for errors,
+ # so the best we can do to propagate the error code from the command
+ # that failed is to parse the error message...
+ errcode = 1
+ m = re.search(r"returned a non-zero code: (\d+)", data["error"])
+ if m:
+ errcode = int(m.group(1))
+ sys.exit(errcode)
+ else:
+ raise NotImplementedError(repr(data))
+ sys.stderr.flush()
+
+
+def docker_image(name, by_tag=False):
+ """
+ Resolve in-tree prebuilt docker image to ``<registry>/<repository>@sha256:<digest>``,
+ or ``<registry>/<repository>:<tag>`` if `by_tag` is `True`.
+ """
+ try:
+ with open(os.path.join(IMAGE_DIR, name, "REGISTRY")) as f:
+ registry = f.read().strip()
+ except IOError:
+ with open(os.path.join(IMAGE_DIR, "REGISTRY")) as f:
+ registry = f.read().strip()
+
+ if not by_tag:
+ hashfile = os.path.join(IMAGE_DIR, name, "HASH")
+ try:
+ with open(hashfile) as f:
+ return "{}/{}@{}".format(registry, name, f.read().strip())
+ except IOError:
+ raise Exception("Failed to read HASH file {}".format(hashfile))
+
+ try:
+ with open(os.path.join(IMAGE_DIR, name, "VERSION")) as f:
+ tag = f.read().strip()
+ except IOError:
+ tag = "latest"
+ return "{}/{}:{}".format(registry, name, tag)
+
+
+class VoidWriter(object):
+ """A file object with write capabilities that does nothing with the written
+ data."""
+
+ def write(self, buf):
+ pass
+
+
+def generate_context_hash(topsrcdir, image_path, image_name, args):
+ """Generates a sha256 hash for context directory used to build an image."""
+
+ return stream_context_tar(
+ topsrcdir, image_path, VoidWriter(), image_name, args=args
+ )
+
+
+class HashingWriter(object):
+ """A file object with write capabilities that hashes the written data at
+ the same time it passes down to a real file object."""
+
+ def __init__(self, writer):
+ self._hash = hashlib.sha256()
+ self._writer = writer
+
+ def write(self, buf):
+ self._hash.update(buf)
+ self._writer.write(buf)
+
+ def hexdigest(self):
+ return six.ensure_text(self._hash.hexdigest())
+
+
+def create_context_tar(topsrcdir, context_dir, out_path, image_name, args):
+ """Create a context tarball.
+
+ A directory ``context_dir`` containing a Dockerfile will be assembled into
+ a gzipped tar file at ``out_path``.
+
+ We also scan the source Dockerfile for special syntax that influences
+ context generation.
+
+ If a line in the Dockerfile has the form ``# %include <path>``,
+ the relative path specified on that line will be matched against
+ files in the source repository and added to the context under the
+ path ``topsrcdir/``. If an entry is a directory, we add all files
+ under that directory.
+
+ If a line in the Dockerfile has the form ``# %ARG <name>``, occurrences of
+ the string ``$<name>`` in subsequent lines are replaced with the value
+ found in the ``args`` argument. Exception: this doesn't apply to VOLUME
+ definitions.
+
+ Returns the SHA-256 hex digest of the created archive.
+ """
+ with open(out_path, "wb") as fh:
+ return stream_context_tar(
+ topsrcdir,
+ context_dir,
+ fh,
+ image_name=image_name,
+ args=args,
+ )
+
+
+def stream_context_tar(topsrcdir, context_dir, out_file, image_name, args):
+ """Like create_context_tar, but streams the tar file to the `out_file` file
+ object."""
+ archive_files = {}
+ replace = []
+ content = []
+
+ context_dir = os.path.join(topsrcdir, context_dir)
+
+ for root, dirs, files in os.walk(context_dir):
+ for f in files:
+ source_path = os.path.join(root, f)
+ archive_path = source_path[len(context_dir) + 1 :]
+ archive_files[archive_path] = source_path
+
+ # Parse Dockerfile for special syntax of extra files to include.
+ with io.open(os.path.join(context_dir, "Dockerfile"), "r") as fh:
+ for line in fh:
+ if line.startswith("# %ARG"):
+ p = line[len("# %ARG ") :].strip()
+ if not args or p not in args:
+ raise Exception("missing argument: {}".format(p))
+ replace.append((re.compile(r"\${}\b".format(p)), args[p]))
+ continue
+
+ for regexp, s in replace:
+ line = re.sub(regexp, s, line)
+
+ content.append(line)
+
+ if not line.startswith("# %include"):
+ continue
+
+ p = line[len("# %include ") :].strip()
+ if os.path.isabs(p):
+ raise Exception("extra include path cannot be absolute: %s" % p)
+
+ fs_path = os.path.normpath(os.path.join(topsrcdir, p))
+ # Check for filesystem traversal exploits.
+ if not fs_path.startswith(topsrcdir):
+ raise Exception("extra include path outside topsrcdir: %s" % p)
+
+ if not os.path.exists(fs_path):
+ raise Exception("extra include path does not exist: %s" % p)
+
+ if os.path.isdir(fs_path):
+ for root, dirs, files in os.walk(fs_path):
+ for f in files:
+ source_path = os.path.join(root, f)
+ rel = source_path[len(fs_path) + 1 :]
+ archive_path = os.path.join("topsrcdir", p, rel)
+ archive_files[archive_path] = source_path
+ else:
+ archive_path = os.path.join("topsrcdir", p)
+ archive_files[archive_path] = fs_path
+
+ archive_files["Dockerfile"] = GeneratedFile(
+ b"".join(six.ensure_binary(s) for s in content)
+ )
+
+ writer = HashingWriter(out_file)
+ create_tar_gz_from_files(writer, archive_files, "{}.tar".format(image_name))
+ return writer.hexdigest()
+
+
+class ImagePathsMap(Mapping):
+ """ImagePathsMap contains the mapping of Docker image names to their
+ context location in the filesystem. The register function allows Thunderbird
+ to define additional images under comm/taskcluster.
+ """
+
+ def __init__(self, config_path, image_dir=IMAGE_DIR):
+ config = load_yaml(GECKO, config_path)
+ self.__update_image_paths(config["jobs"], image_dir)
+
+ def __getitem__(self, key):
+ return self.__dict__[key]
+
+ def __iter__(self):
+ return iter(self.__dict__)
+
+ def __len__(self):
+ return len(self.__dict__)
+
+ def __update_image_paths(self, jobs, image_dir):
+ self.__dict__.update(
+ {
+ k: os.path.join(image_dir, v.get("definition", k))
+ for k, v in jobs.items()
+ }
+ )
+
+ def register(self, jobs_config_path, image_dir):
+ """Register additional image_paths. In this case, there is no 'jobs'
+ key in the loaded YAML as this file is loaded via jobs-from in kind.yml."""
+ jobs = load_yaml(GECKO, jobs_config_path)
+ self.__update_image_paths(jobs, image_dir)
+
+
+image_paths = ImagePathsMap("taskcluster/ci/docker-image/kind.yml")
+
+
+def image_path(name):
+ if name in image_paths:
+ return image_paths[name]
+ return os.path.join(IMAGE_DIR, name)
+
+
+@memoize
+def parse_volumes(image):
+ """Parse VOLUME entries from a Dockerfile for an image."""
+ volumes = set()
+
+ path = image_path(image)
+
+ with open(os.path.join(path, "Dockerfile"), "rb") as fh:
+ for line in fh:
+ line = line.strip()
+ # We assume VOLUME definitions don't use %ARGS.
+ if not line.startswith(b"VOLUME "):
+ continue
+
+ v = line.split(None, 1)[1]
+ if v.startswith(b"["):
+ raise ValueError(
+ "cannot parse array syntax for VOLUME; "
+ "convert to multiple entries"
+ )
+
+ volumes |= set([six.ensure_text(v) for v in v.split()])
+
+ return volumes
diff --git a/taskcluster/taskgraph/util/hash.py b/taskcluster/taskgraph/util/hash.py
new file mode 100644
index 0000000000..04b946be71
--- /dev/null
+++ b/taskcluster/taskgraph/util/hash.py
@@ -0,0 +1,58 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+from mozbuild.util import memoize
+import mozpack.path as mozpath
+from mozversioncontrol import get_repository_object
+import hashlib
+import io
+import six
+
+
+@memoize
+def hash_path(path):
+ """Hash a single file.
+
+ Returns the SHA-256 hash in hex form.
+ """
+ with io.open(path, mode="rb") as fh:
+ return hashlib.sha256(fh.read()).hexdigest()
+
+
+@memoize
+def get_file_finder(base_path):
+ return get_repository_object(base_path).get_tracked_files_finder()
+
+
+def hash_paths(base_path, patterns):
+ """
+ Give a list of path patterns, return a digest of the contents of all
+ the corresponding files, similarly to git tree objects or mercurial
+ manifests.
+
+ Each file is hashed. The list of all hashes and file paths is then
+ itself hashed to produce the result.
+ """
+ finder = get_file_finder(base_path)
+ h = hashlib.sha256()
+ files = {}
+ for pattern in patterns:
+ found = list(finder.find(pattern))
+ if found:
+ files.update(found)
+ else:
+ raise Exception("%s did not match anything" % pattern)
+ for path in sorted(files.keys()):
+ if path.endswith((".pyc", ".pyd", ".pyo")):
+ continue
+ h.update(
+ six.ensure_binary(
+ "{} {}\n".format(
+ hash_path(mozpath.abspath(mozpath.join(base_path, path))),
+ mozpath.normsep(path),
+ )
+ )
+ )
+ return h.hexdigest()
diff --git a/taskcluster/taskgraph/util/hg.py b/taskcluster/taskgraph/util/hg.py
new file mode 100644
index 0000000000..65dd412354
--- /dev/null
+++ b/taskcluster/taskgraph/util/hg.py
@@ -0,0 +1,134 @@
+# -*- coding: utf-8 -*-
+
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import logging
+
+import requests
+import six
+import subprocess
+from redo import retry
+
+from mozbuild.util import memoize
+
+logger = logging.getLogger(__name__)
+
+PUSHLOG_CHANGESET_TMPL = (
+ "{repository}/json-pushes?version=2&changeset={revision}&tipsonly=1"
+)
+PUSHLOG_PUSHES_TMPL = (
+ "{repository}/json-pushes/?version=2&startID={push_id_start}&endID={push_id_end}"
+)
+
+
+def _query_pushlog(url):
+ response = retry(
+ requests.get,
+ attempts=5,
+ sleeptime=10,
+ args=(url,),
+ kwargs={"timeout": 60, "headers": {"User-Agent": "TaskCluster"}},
+ )
+
+ return response.json()["pushes"]
+
+
+def find_hg_revision_push_info(repository, revision):
+ """Given the parameters for this action and a revision, find the
+ pushlog_id of the revision."""
+ url = PUSHLOG_CHANGESET_TMPL.format(repository=repository, revision=revision)
+
+ pushes = _query_pushlog(url)
+
+ if len(pushes) != 1:
+ raise RuntimeError(
+ "Found {} pushlog_ids, expected 1, for {} revision {}: {}".format(
+ len(pushes), repository, revision, pushes
+ )
+ )
+
+ pushid = list(pushes.keys())[0]
+ return {
+ "pushdate": pushes[pushid]["date"],
+ "pushid": pushid,
+ "user": pushes[pushid]["user"],
+ }
+
+
+@memoize
+def get_push_data(repository, project, push_id_start, push_id_end):
+ url = PUSHLOG_PUSHES_TMPL.format(
+ repository=repository,
+ push_id_start=push_id_start - 1,
+ push_id_end=push_id_end,
+ )
+
+ try:
+ pushes = _query_pushlog(url)
+
+ return {
+ push_id: pushes[str(push_id)]
+ for push_id in range(push_id_start, push_id_end + 1)
+ }
+
+ # In the event of request times out, requests will raise a TimeoutError.
+ except requests.exceptions.Timeout:
+ logger.warning("json-pushes timeout")
+
+ # In the event of a network problem (e.g. DNS failure, refused connection, etc),
+ # requests will raise a ConnectionError.
+ except requests.exceptions.ConnectionError:
+ logger.warning("json-pushes connection error")
+
+ # In the event of the rare invalid HTTP response(e.g 404, 401),
+ # requests will raise an HTTPError exception
+ except requests.exceptions.HTTPError:
+ logger.warning("Bad Http response")
+
+ # When we get invalid JSON (i.e. 500 error), it results in a ValueError (bug 1313426)
+ except ValueError as error:
+ logger.warning("Invalid JSON, possible server error: {}".format(error))
+
+ # We just print the error out as a debug message if we failed to catch the exception above
+ except requests.exceptions.RequestException as error:
+ logger.warning(error)
+
+ return None
+
+
+def get_hg_revision_branch(root, revision):
+ """Given the parameters for a revision, find the hg_branch (aka
+ relbranch) of the revision."""
+ return six.ensure_text(
+ subprocess.check_output(
+ [
+ "hg",
+ "identify",
+ "-T",
+ "{branch}",
+ "--rev",
+ revision,
+ ],
+ cwd=root,
+ universal_newlines=True,
+ )
+ )
+
+
+# For these functions, we assume that run-task has correctly checked out the
+# revision indicated by GECKO_HEAD_REF, so all that remains is to see what the
+# current revision is. Mercurial refers to that as `.`.
+def get_hg_commit_message(root):
+ return six.ensure_text(
+ subprocess.check_output(["hg", "log", "-r", ".", "-T", "{desc}"], cwd=root)
+ )
+
+
+def calculate_head_rev(root):
+ return six.ensure_text(
+ subprocess.check_output(["hg", "log", "-r", ".", "-T", "{node}"], cwd=root)
+ )
diff --git a/taskcluster/taskgraph/util/keyed_by.py b/taskcluster/taskgraph/util/keyed_by.py
new file mode 100644
index 0000000000..c86447f75f
--- /dev/null
+++ b/taskcluster/taskgraph/util/keyed_by.py
@@ -0,0 +1,89 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+from .attributes import keymatch
+
+
+def evaluate_keyed_by(value, item_name, attributes, defer=None):
+ """
+ For values which can either accept a literal value, or be keyed by some
+ attributes, perform that lookup and return the result.
+
+ For example, given item::
+
+ by-test-platform:
+ macosx-10.11/debug: 13
+ win.*: 6
+ default: 12
+
+ a call to `evaluate_keyed_by(item, 'thing-name', {'test-platform': 'linux96')`
+ would return `12`.
+
+ The `item_name` parameter is used to generate useful error messages.
+ Items can be nested as deeply as desired::
+
+ by-test-platform:
+ win.*:
+ by-project:
+ ash: ..
+ cedar: ..
+ linux: 13
+ default: 12
+
+ The `defer` parameter allows evaluating a by-* entry at a later time. In the
+ example above it's possible that the project attribute hasn't been set
+ yet, in which case we'd want to stop before resolving that subkey and then
+ call this function again later. This can be accomplished by setting
+ `defer=["project"]` in this example.
+ """
+ while True:
+ if not isinstance(value, dict) or len(value) != 1:
+ return value
+ value_key = next(iter(value))
+ if not value_key.startswith("by-"):
+ return value
+
+ keyed_by = value_key[3:] # strip off 'by-' prefix
+
+ if defer and keyed_by in defer:
+ return value
+
+ key = attributes.get(keyed_by)
+ alternatives = next(iter(value.values()))
+
+ if len(alternatives) == 1 and "default" in alternatives:
+ # Error out when only 'default' is specified as only alternatives,
+ # because we don't need to by-{keyed_by} there.
+ raise Exception(
+ "Keyed-by '{}' unnecessary with only value 'default' "
+ "found, when determining item {}".format(keyed_by, item_name)
+ )
+
+ if key is None:
+ if "default" in alternatives:
+ value = alternatives["default"]
+ continue
+ else:
+ raise Exception(
+ "No attribute {} and no value for 'default' found "
+ "while determining item {}".format(keyed_by, item_name)
+ )
+
+ matches = keymatch(alternatives, key)
+ if len(matches) > 1:
+ raise Exception(
+ "Multiple matching values for {} {!r} found while "
+ "determining item {}".format(keyed_by, key, item_name)
+ )
+ elif matches:
+ value = matches[0]
+ continue
+
+ raise Exception(
+ "No {} matching {!r} nor 'default' found while determining item {}".format(
+ keyed_by, key, item_name
+ )
+ )
diff --git a/taskcluster/taskgraph/util/parameterization.py b/taskcluster/taskgraph/util/parameterization.py
new file mode 100644
index 0000000000..0486773605
--- /dev/null
+++ b/taskcluster/taskgraph/util/parameterization.py
@@ -0,0 +1,107 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import re
+
+import six
+
+from taskgraph.util.time import json_time_from_now
+from taskgraph.util.taskcluster import get_artifact_url
+
+TASK_REFERENCE_PATTERN = re.compile("<([^>]+)>")
+ARTIFACT_REFERENCE_PATTERN = re.compile("<([^/]+)/([^>]+)>")
+
+
+def _recurse(val, param_fns):
+ def recurse(val):
+ if isinstance(val, list):
+ return [recurse(v) for v in val]
+ elif isinstance(val, dict):
+ if len(val) == 1:
+ for param_key, param_fn in param_fns.items():
+ if set(six.iterkeys(val)) == {param_key}:
+ return param_fn(val[param_key])
+ return {k: recurse(v) for k, v in six.iteritems(val)}
+ else:
+ return val
+
+ return recurse(val)
+
+
+def resolve_timestamps(now, task_def):
+ """Resolve all instances of `{'relative-datestamp': '..'}` in the given task definition"""
+ return _recurse(
+ task_def,
+ {
+ "relative-datestamp": lambda v: json_time_from_now(v, now),
+ },
+ )
+
+
+def resolve_task_references(label, task_def, task_id, decision_task_id, dependencies):
+ """Resolve all instances of
+ {'task-reference': '..<..>..'}
+ and
+ {'artifact-reference`: '..<dependency/artifact/path>..'}
+ in the given task definition, using the given dependencies
+
+ """
+
+ def task_reference(val):
+ def repl(match):
+ key = match.group(1)
+ if key == "self":
+ return task_id
+ elif key == "decision":
+ return decision_task_id
+ try:
+ return dependencies[key]
+ except KeyError:
+ # handle escaping '<'
+ if key == "<":
+ return key
+ raise KeyError(
+ "task '{}' has no dependency named '{}'".format(label, key)
+ )
+
+ return TASK_REFERENCE_PATTERN.sub(repl, val)
+
+ def artifact_reference(val):
+ def repl(match):
+ dependency, artifact_name = match.group(1, 2)
+
+ if dependency == "self":
+ raise KeyError(
+ "task '{}' can't reference artifacts of self".format(label)
+ )
+ elif dependency == "decision":
+ task_id = decision_task_id
+ else:
+ try:
+ task_id = dependencies[dependency]
+ except KeyError:
+ raise KeyError(
+ "task '{}' has no dependency named '{}'".format(
+ label, dependency
+ )
+ )
+
+ assert artifact_name.startswith(
+ "public/"
+ ), "artifact-reference only supports public artifacts, not `{}`".format(
+ artifact_name
+ )
+ return get_artifact_url(task_id, artifact_name)
+
+ return ARTIFACT_REFERENCE_PATTERN.sub(repl, val)
+
+ return _recurse(
+ task_def,
+ {
+ "task-reference": task_reference,
+ "artifact-reference": artifact_reference,
+ },
+ )
diff --git a/taskcluster/taskgraph/util/partials.py b/taskcluster/taskgraph/util/partials.py
new file mode 100644
index 0000000000..50a64d89de
--- /dev/null
+++ b/taskcluster/taskgraph/util/partials.py
@@ -0,0 +1,301 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import logging
+
+import requests
+import six
+
+import redo
+from taskgraph.util.scriptworker import (
+ BALROG_SCOPE_ALIAS_TO_PROJECT,
+ BALROG_SERVER_SCOPES,
+)
+
+logger = logging.getLogger(__name__)
+
+PLATFORM_RENAMES = {
+ "windows2012-32": "win32",
+ "windows2012-64": "win64",
+ "windows2012-aarch64": "win64-aarch64",
+ "osx-cross": "macosx64",
+ "osx": "macosx64",
+}
+
+BALROG_PLATFORM_MAP = {
+ "linux": ["Linux_x86-gcc3"],
+ "linux32": ["Linux_x86-gcc3"],
+ "linux64": ["Linux_x86_64-gcc3"],
+ "linux64-asan-reporter": ["Linux_x86_64-gcc3-asan"],
+ "macosx64": [
+ "Darwin_x86_64-gcc3-u-i386-x86_64",
+ "Darwin_x86-gcc3-u-i386-x86_64",
+ "Darwin_aarch64-gcc3",
+ "Darwin_x86-gcc3",
+ "Darwin_x86_64-gcc3",
+ ],
+ "win32": ["WINNT_x86-msvc", "WINNT_x86-msvc-x86", "WINNT_x86-msvc-x64"],
+ "win64": ["WINNT_x86_64-msvc", "WINNT_x86_64-msvc-x64"],
+ "win64-asan-reporter": ["WINNT_x86_64-msvc-x64-asan"],
+ "win64-aarch64": [
+ "WINNT_aarch64-msvc-aarch64",
+ ],
+}
+
+FTP_PLATFORM_MAP = {
+ "Darwin_x86-gcc3": "mac",
+ "Darwin_x86-gcc3-u-i386-x86_64": "mac",
+ "Darwin_x86_64-gcc3": "mac",
+ "Darwin_x86_64-gcc3-u-i386-x86_64": "mac",
+ "Darwin_aarch64-gcc3": "mac",
+ "Linux_x86-gcc3": "linux-i686",
+ "Linux_x86_64-gcc3": "linux-x86_64",
+ "Linux_x86_64-gcc3-asan": "linux-x86_64-asan-reporter",
+ "WINNT_x86_64-msvc-x64-asan": "win64-asan-reporter",
+ "WINNT_x86-msvc": "win32",
+ "WINNT_x86-msvc-x64": "win32",
+ "WINNT_x86-msvc-x86": "win32",
+ "WINNT_x86_64-msvc": "win64",
+ "WINNT_x86_64-msvc-x64": "win64",
+ "WINNT_aarch64-msvc-aarch64": "win64-aarch64",
+}
+
+
+def get_balrog_platform_name(platform):
+ """Convert build platform names into balrog platform names.
+
+ Remove known values instead to catch aarch64 and other platforms
+ that may be added.
+ """
+ removals = ["-devedition", "-shippable"]
+ for remove in removals:
+ platform = platform.replace(remove, "")
+ return PLATFORM_RENAMES.get(platform, platform)
+
+
+def _sanitize_platform(platform):
+ platform = get_balrog_platform_name(platform)
+ if platform not in BALROG_PLATFORM_MAP:
+ return platform
+ return BALROG_PLATFORM_MAP[platform][0]
+
+
+def get_builds(release_history, platform, locale):
+ """Examine cached balrog release history and return the list of
+ builds we need to generate diffs from"""
+ platform = _sanitize_platform(platform)
+ return release_history.get(platform, {}).get(locale, {})
+
+
+def get_partials_artifacts_from_params(release_history, platform, locale):
+ platform = _sanitize_platform(platform)
+ return [
+ (artifact, details.get("previousVersion", None))
+ for artifact, details in release_history.get(platform, {})
+ .get(locale, {})
+ .items()
+ ]
+
+
+def get_partials_info_from_params(release_history, platform, locale):
+ platform = _sanitize_platform(platform)
+
+ artifact_map = {}
+ for k in release_history.get(platform, {}).get(locale, {}):
+ details = release_history[platform][locale][k]
+ attributes = ("buildid", "previousBuildNumber", "previousVersion")
+ artifact_map[k] = {
+ attr: details[attr] for attr in attributes if attr in details
+ }
+ return artifact_map
+
+
+def _retry_on_http_errors(url, verify, params, errors):
+ if params:
+ params_str = "&".join("=".join([k, str(v)]) for k, v in six.iteritems(params))
+ else:
+ params_str = ""
+ logger.info("Connecting to %s?%s", url, params_str)
+ for _ in redo.retrier(sleeptime=5, max_sleeptime=30, attempts=10):
+ try:
+ req = requests.get(url, verify=verify, params=params, timeout=10)
+ req.raise_for_status()
+ return req
+ except requests.HTTPError as e:
+ if e.response.status_code in errors:
+ logger.exception(
+ "Got HTTP %s trying to reach %s", e.response.status_code, url
+ )
+ else:
+ raise
+ else:
+ raise
+
+
+def get_sorted_releases(product, branch):
+ """Returns a list of release names from Balrog.
+ :param product: product name, AKA appName
+ :param branch: branch name, e.g. mozilla-central
+ :return: a sorted list of release names, most recent first.
+ """
+ url = "{}/releases".format(_get_balrog_api_root(branch))
+ params = {
+ "product": product,
+ # Adding -nightly-2 (2 stands for the beginning of build ID
+ # based on date) should filter out release and latest blobs.
+ # This should be changed to -nightly-3 in 3000 ;)
+ "name_prefix": "{}-{}-nightly-2".format(product, branch),
+ "names_only": True,
+ }
+ req = _retry_on_http_errors(url=url, verify=True, params=params, errors=[500])
+ releases = req.json()["names"]
+ releases = sorted(releases, reverse=True)
+ return releases
+
+
+def get_release_builds(release, branch):
+ url = "{}/releases/{}".format(_get_balrog_api_root(branch), release)
+ req = _retry_on_http_errors(url=url, verify=True, params=None, errors=[500])
+ return req.json()
+
+
+def _get_balrog_api_root(branch):
+ # Query into the scopes scriptworker uses to make sure we check against the same balrog server
+ # That our jobs would use.
+ scope = None
+ for alias, projects in BALROG_SCOPE_ALIAS_TO_PROJECT:
+ if branch in projects and alias in BALROG_SERVER_SCOPES:
+ scope = BALROG_SERVER_SCOPES[alias]
+ break
+ else:
+ scope = BALROG_SERVER_SCOPES["default"]
+
+ if scope == "balrog:server:dep":
+ return "https://stage.balrog.nonprod.cloudops.mozgcp.net/api/v1"
+ else:
+ return "https://aus5.mozilla.org/api/v1"
+
+
+def find_localtest(fileUrls):
+ for channel in fileUrls:
+ if "-localtest" in channel:
+ return channel
+
+
+def populate_release_history(
+ product, branch, maxbuilds=4, maxsearch=10, partial_updates=None
+):
+ # Assuming we are using release branches when we know the list of previous
+ # releases in advance
+ if partial_updates:
+ return _populate_release_history(
+ product, branch, partial_updates=partial_updates
+ )
+ else:
+ return _populate_nightly_history(
+ product, branch, maxbuilds=maxbuilds, maxsearch=maxsearch
+ )
+
+
+def _populate_nightly_history(product, branch, maxbuilds=4, maxsearch=10):
+ """Find relevant releases in Balrog
+ Not all releases have all platforms and locales, due
+ to Taskcluster migration.
+
+ Args:
+ product (str): capitalized product name, AKA appName, e.g. Firefox
+ branch (str): branch name (mozilla-central)
+ maxbuilds (int): Maximum number of historical releases to populate
+ maxsearch(int): Traverse at most this many releases, to avoid
+ working through the entire history.
+ Returns:
+ json object based on data from balrog api
+
+ results = {
+ 'platform1': {
+ 'locale1': {
+ 'buildid1': mar_url,
+ 'buildid2': mar_url,
+ 'buildid3': mar_url,
+ },
+ 'locale2': {
+ 'target.partial-1.mar': {'buildid1': 'mar_url'},
+ }
+ },
+ 'platform2': {
+ }
+ }
+ """
+ last_releases = get_sorted_releases(product, branch)
+
+ partial_mar_tmpl = "target.partial-{}.mar"
+
+ builds = dict()
+ for release in last_releases[:maxsearch]:
+ # maxbuilds in all categories, don't make any more queries
+ full = len(builds) > 0 and all(
+ len(builds[platform][locale]) >= maxbuilds
+ for platform in builds
+ for locale in builds[platform]
+ )
+ if full:
+ break
+ history = get_release_builds(release, branch)
+
+ for platform in history["platforms"]:
+ if "alias" in history["platforms"][platform]:
+ continue
+ if platform not in builds:
+ builds[platform] = dict()
+ for locale in history["platforms"][platform]["locales"]:
+ if locale not in builds[platform]:
+ builds[platform][locale] = dict()
+ if len(builds[platform][locale]) >= maxbuilds:
+ continue
+ buildid = history["platforms"][platform]["locales"][locale]["buildID"]
+ url = history["platforms"][platform]["locales"][locale]["completes"][0][
+ "fileUrl"
+ ]
+ nextkey = len(builds[platform][locale]) + 1
+ builds[platform][locale][partial_mar_tmpl.format(nextkey)] = {
+ "buildid": buildid,
+ "mar_url": url,
+ }
+ return builds
+
+
+def _populate_release_history(product, branch, partial_updates):
+ builds = dict()
+ for version, release in six.iteritems(partial_updates):
+ prev_release_blob = "{product}-{version}-build{build_number}".format(
+ product=product, version=version, build_number=release["buildNumber"]
+ )
+ partial_mar_key = "target-{version}.partial.mar".format(version=version)
+ history = get_release_builds(prev_release_blob, branch)
+ # use one of the localtest channels to avoid relying on bouncer
+ localtest = find_localtest(history["fileUrls"])
+ url_pattern = history["fileUrls"][localtest]["completes"]["*"]
+
+ for platform in history["platforms"]:
+ if "alias" in history["platforms"][platform]:
+ continue
+ if platform not in builds:
+ builds[platform] = dict()
+ for locale in history["platforms"][platform]["locales"]:
+ if locale not in builds[platform]:
+ builds[platform][locale] = dict()
+ buildid = history["platforms"][platform]["locales"][locale]["buildID"]
+ url = url_pattern.replace(
+ "%OS_FTP%", FTP_PLATFORM_MAP[platform]
+ ).replace("%LOCALE%", locale)
+ builds[platform][locale][partial_mar_key] = {
+ "buildid": buildid,
+ "mar_url": url,
+ "previousVersion": version,
+ "previousBuildNumber": release["buildNumber"],
+ "product": product,
+ }
+ return builds
diff --git a/taskcluster/taskgraph/util/partners.py b/taskcluster/taskgraph/util/partners.py
new file mode 100644
index 0000000000..1d820bf55e
--- /dev/null
+++ b/taskcluster/taskgraph/util/partners.py
@@ -0,0 +1,557 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+from copy import deepcopy
+import json
+import logging
+import os
+from redo import retry
+import requests
+import xml.etree.ElementTree as ET
+
+from taskgraph.util.attributes import release_level
+from taskgraph.util.schema import resolve_keyed_by
+import six
+import yaml
+
+# Suppress chatty requests logging
+logging.getLogger("requests").setLevel(logging.WARNING)
+
+log = logging.getLogger(__name__)
+
+GITHUB_API_ENDPOINT = "https://api.github.com/graphql"
+
+"""
+LOGIN_QUERY, MANIFEST_QUERY, and REPACK_CFG_QUERY are all written to the Github v4 API,
+which users GraphQL. See https://developer.github.com/v4/
+"""
+
+LOGIN_QUERY = """query {
+ viewer {
+ login
+ name
+ }
+}
+"""
+
+# Returns the contents of default.xml from a manifest repository
+MANIFEST_QUERY = """query {
+ repository(owner:"%(owner)s", name:"%(repo)s") {
+ object(expression: "master:%(file)s") {
+ ... on Blob {
+ text
+ }
+ }
+ }
+}
+"""
+# Example response:
+# {
+# "data": {
+# "repository": {
+# "object": {
+# "text": "<?xml version=\"1.0\" ?>\n<manifest>\n " +
+# "<remote fetch=\"git@github.com:mozilla-partners/\" name=\"mozilla-partners\"/>\n " +
+# "<remote fetch=\"git@github.com:mozilla/\" name=\"mozilla\"/>\n\n " +
+# "<project name=\"repack-scripts\" path=\"scripts\" remote=\"mozilla-partners\" " +
+# "revision=\"master\"/>\n <project name=\"build-tools\" path=\"scripts/tools\" " +
+# "remote=\"mozilla\" revision=\"master\"/>\n <project name=\"mozilla-EME-free\" " +
+# "path=\"partners/mozilla-EME-free\" remote=\"mozilla-partners\" " +
+# "revision=\"master\"/>\n</manifest>\n"
+# }
+# }
+# }
+# }
+
+# Returns the contents of desktop/*/repack.cfg for a partner repository
+REPACK_CFG_QUERY = """query{
+ repository(owner:"%(owner)s", name:"%(repo)s") {
+ object(expression: "%(revision)s:desktop/"){
+ ... on Tree {
+ entries {
+ name
+ object {
+ ... on Tree {
+ entries {
+ name
+ object {
+ ... on Blob {
+ text
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+"""
+# Example response:
+# {
+# "data": {
+# "repository": {
+# "object": {
+# "entries": [
+# {
+# "name": "mozilla-EME-free",
+# "object": {
+# "entries": [
+# {
+# "name": "distribution",
+# "object": {}
+# },
+# {
+# "name": "repack.cfg",
+# "object": {
+# "text": "aus=\"mozilla-EMEfree\"\ndist_id=\"mozilla-EMEfree\"\n" +
+# "dist_version=\"1.0\"\nlinux-i686=true\nlinux-x86_64=true\n" +
+# " locales=\"ach af de en-US\"\nmac=true\nwin32=true\nwin64=true\n" +
+# "output_dir=\"%(platform)s-EME-free/%(locale)s\"\n\n" +
+# "# Upload params\nbucket=\"net-mozaws-prod-delivery-firefox\"\n" +
+# "upload_to_candidates=true\n"
+# }
+# }
+# ]
+# }
+# }
+# ]
+# }
+# }
+# }
+# }
+
+# Map platforms in repack.cfg into their equivalents in taskcluster
+TC_PLATFORM_PER_FTP = {
+ "linux-i686": "linux-shippable",
+ "linux-x86_64": "linux64-shippable",
+ "mac": "macosx64-shippable",
+ "win32": "win32-shippable",
+ "win64": "win64-shippable",
+ "win64-aarch64": "win64-aarch64-shippable",
+}
+
+TASKCLUSTER_PROXY_SECRET_ROOT = "http://taskcluster/secrets/v1/secret"
+
+LOCALES_FILE = os.path.join(
+ os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))),
+ "browser",
+ "locales",
+ "l10n-changesets.json",
+)
+
+# cache data at the module level
+partner_configs = {}
+
+
+def get_token(params):
+ """We use a Personal Access Token from Github to lookup partner config. No extra scopes are
+ needed on the token to read public repositories, but need the 'repo' scope to see private
+ repositories. This is not fine grained and also grants r/w access, but is revoked at the repo
+ level.
+ """
+
+ # Allow for local taskgraph debugging
+ if os.environ.get("GITHUB_API_TOKEN"):
+ return os.environ["GITHUB_API_TOKEN"]
+
+ # The 'usual' method - via taskClusterProxy for decision tasks
+ url = "{secret_root}/project/releng/gecko/build/level-{level}/partner-github-api".format(
+ secret_root=TASKCLUSTER_PROXY_SECRET_ROOT, **params
+ )
+ try:
+ resp = retry(
+ requests.get,
+ attempts=2,
+ sleeptime=10,
+ args=(url,),
+ kwargs={"timeout": 60, "headers": ""},
+ )
+ j = resp.json()
+ return j["secret"]["key"]
+ except (requests.ConnectionError, ValueError, KeyError):
+ raise RuntimeError("Could not get Github API token to lookup partner data")
+
+
+def query_api(query, token):
+ """ Make a query with a Github auth header, returning the json """
+ headers = {"Authorization": "bearer %s" % token}
+ r = requests.post(GITHUB_API_ENDPOINT, json={"query": query}, headers=headers)
+ r.raise_for_status()
+
+ j = r.json()
+ if "errors" in j:
+ raise RuntimeError("Github query error - %s", j["errors"])
+ return j
+
+
+def check_login(token):
+ log.debug("Checking we have a valid login")
+ query_api(LOGIN_QUERY, token)
+
+
+def get_repo_params(repo):
+ """ Parse the organisation and repo name from an https or git url for a repo """
+ if repo.startswith("https"):
+ # eg https://github.com/mozilla-partners/mozilla-EME-free
+ return repo.rsplit("/", 2)[-2:]
+ elif repo.startswith("git@"):
+ # eg git@github.com:mozilla-partners/mailru.git
+ repo = repo.replace(".git", "")
+ return repo.split(":")[-1].split("/")
+
+
+def get_partners(manifestRepo, token):
+ """Given the url to a manifest repository, retrieve the default.xml and parse it into a
+ list of partner repos.
+ """
+ log.debug("Querying for manifest default.xml in %s", manifestRepo)
+ owner, repo = get_repo_params(manifestRepo)
+ query = MANIFEST_QUERY % {"owner": owner, "repo": repo, "file": "default.xml"}
+ raw_manifest = query_api(query, token)
+ log.debug("Raw manifest: %s", raw_manifest)
+ if not raw_manifest["data"]["repository"]:
+ raise RuntimeError(
+ "Couldn't load partner manifest at %s, insufficient permissions ?"
+ % manifestRepo
+ )
+ e = ET.fromstring(raw_manifest["data"]["repository"]["object"]["text"])
+
+ remotes = {}
+ partners = {}
+ for child in e:
+ if child.tag == "remote":
+ name = child.attrib["name"]
+ url = child.attrib["fetch"]
+ remotes[name] = url
+ log.debug("Added remote %s at %s", name, url)
+ elif child.tag == "project":
+ # we don't need to check any code repos
+ if "scripts" in child.attrib["path"]:
+ continue
+ owner, _ = get_repo_params(remotes[child.attrib["remote"]] + "_")
+ partner_url = {
+ "owner": owner,
+ "repo": child.attrib["name"],
+ "revision": child.attrib["revision"],
+ }
+ partners[child.attrib["name"]] = partner_url
+ log.debug(
+ "Added partner %s at revision %s"
+ % (partner_url["repo"], partner_url["revision"])
+ )
+ return partners
+
+
+def parse_config(data):
+ """Parse a single repack.cfg file into a python dictionary.
+ data is contents of the file, in "foo=bar\nbaz=buzz" style. We do some translation on
+ locales and platforms data, otherwise passthrough
+ """
+ ALLOWED_KEYS = (
+ "locales",
+ "platforms",
+ "upload_to_candidates",
+ "repack_stub_installer",
+ "publish_to_releases",
+ )
+ config = {"platforms": []}
+ for l in data.splitlines():
+ if "=" in l:
+ l = str(l)
+ key, value = l.split("=", 1)
+ value = value.strip("'\"").rstrip("'\"")
+ if key in TC_PLATFORM_PER_FTP.keys():
+ if value.lower() == "true":
+ config["platforms"].append(TC_PLATFORM_PER_FTP[key])
+ continue
+ if key not in ALLOWED_KEYS:
+ continue
+ if key == "locales":
+ # a list please
+ value = value.split(" ")
+ config[key] = value
+ return config
+
+
+def get_repack_configs(repackRepo, token):
+ """ For a partner repository, retrieve all the repack.cfg files and parse them into a dict """
+ log.debug("Querying for configs in %s", repackRepo)
+ query = REPACK_CFG_QUERY % repackRepo
+ raw_configs = query_api(query, token)
+ raw_configs = raw_configs["data"]["repository"]["object"]["entries"]
+
+ configs = {}
+ for sub_config in raw_configs:
+ name = sub_config["name"]
+ for file in sub_config["object"].get("entries", []):
+ if file["name"] != "repack.cfg":
+ continue
+ configs[name] = parse_config(file["object"]["text"])
+ return configs
+
+
+def get_attribution_config(manifestRepo, token):
+ log.debug("Querying for manifest attribution_config.yml in %s", manifestRepo)
+ owner, repo = get_repo_params(manifestRepo)
+ query = MANIFEST_QUERY % {
+ "owner": owner,
+ "repo": repo,
+ "file": "attribution_config.yml",
+ }
+ raw_manifest = query_api(query, token)
+ if not raw_manifest["data"]["repository"]:
+ raise RuntimeError(
+ "Couldn't load partner manifest at %s, insufficient permissions ?"
+ % manifestRepo
+ )
+ # no file has been set up, gracefully continue
+ if raw_manifest["data"]["repository"]["object"] is None:
+ log.debug("No attribution_config.yml file found")
+ return {}
+
+ return yaml.safe_load(raw_manifest["data"]["repository"]["object"]["text"])
+
+
+def get_partner_config_by_url(manifest_url, kind, token, partner_subset=None):
+ """Retrieve partner data starting from the manifest url, which points to a repository
+ containing a default.xml that is intended to be drive the Google tool 'repo'. It
+ descends into each partner repo to lookup and parse the repack.cfg file(s).
+
+ If partner_subset is a list of sub_config names only return data for those.
+
+ Supports caching data by kind to avoid repeated requests, relying on the related kinds for
+ partner repacking, signing, repackage, repackage signing all having the same kind prefix.
+ """
+ if not manifest_url:
+ raise RuntimeError("Manifest url for {} not defined".format(kind))
+ if kind not in partner_configs:
+ log.info("Looking up data for %s from %s", kind, manifest_url)
+ check_login(token)
+ if kind == "release-partner-attribution":
+ partner_configs[kind] = get_attribution_config(manifest_url, token)
+ else:
+ partners = get_partners(manifest_url, token)
+
+ partner_configs[kind] = {}
+ for partner, partner_url in partners.items():
+ if partner_subset and partner not in partner_subset:
+ continue
+ partner_configs[kind][partner] = get_repack_configs(partner_url, token)
+
+ return partner_configs[kind]
+
+
+def check_if_partners_enabled(config, tasks):
+ if (
+ (
+ config.params["release_enable_partner_repack"]
+ and config.kind.startswith("release-partner-repack")
+ )
+ or (
+ config.params["release_enable_partner_attribution"]
+ and config.kind.startswith("release-partner-attribution")
+ )
+ or (
+ config.params["release_enable_emefree"]
+ and config.kind.startswith("release-eme-free-")
+ )
+ ):
+ for task in tasks:
+ yield task
+
+
+def get_partner_config_by_kind(config, kind):
+ """Retrieve partner data starting from the manifest url, which points to a repository
+ containing a default.xml that is intended to be drive the Google tool 'repo'. It
+ descends into each partner repo to lookup and parse the repack.cfg file(s).
+
+ Supports caching data by kind to avoid repeated requests, relying on the related kinds for
+ partner repacking, signing, repackage, repackage signing all having the same kind prefix.
+ """
+ partner_subset = config.params["release_partners"]
+ partner_configs = config.params["release_partner_config"] or {}
+
+ # TODO eme-free should be a partner; we shouldn't care about per-kind
+ for k in partner_configs:
+ if kind.startswith(k):
+ kind_config = partner_configs[k]
+ break
+ else:
+ return {}
+ # if we're only interested in a subset of partners we remove the rest
+ if partner_subset:
+ if kind.startswith("release-partner-repack"):
+ # TODO - should be fatal to have an unknown partner in partner_subset
+ for partner in [p for p in kind_config.keys() if p not in partner_subset]:
+ del kind_config[partner]
+ elif kind.startswith("release-partner-attribution") and isinstance(
+ kind_config, dict
+ ):
+ all_configs = deepcopy(kind_config.get("configs", []))
+ kind_config["configs"] = []
+ for this_config in all_configs:
+ if this_config["campaign"] in partner_subset:
+ kind_config["configs"].append(this_config)
+ return kind_config
+
+
+def _fix_subpartner_locales(orig_config, all_locales):
+ subpartner_config = deepcopy(orig_config)
+ # Get an ordered list of subpartner locales that is a subset of all_locales
+ subpartner_config["locales"] = sorted(
+ list(set(orig_config["locales"]) & set(all_locales))
+ )
+ return subpartner_config
+
+
+def fix_partner_config(orig_config):
+ pc = {}
+ with open(LOCALES_FILE, "r") as fh:
+ all_locales = list(json.load(fh).keys())
+ # l10n-changesets.json doesn't include en-US, but the repack list does
+ if "en-US" not in all_locales:
+ all_locales.append("en-US")
+ for kind, kind_config in six.iteritems(orig_config):
+ if kind == "release-partner-attribution":
+ pc[kind] = {}
+ if kind_config:
+ pc[kind] = {"defaults": kind_config["defaults"]}
+ for config in kind_config["configs"]:
+ # Make sure our locale list is a subset of all_locales
+ pc[kind].setdefault("configs", []).append(
+ _fix_subpartner_locales(config, all_locales)
+ )
+ else:
+ for partner, partner_config in six.iteritems(kind_config):
+ for subpartner, subpartner_config in six.iteritems(partner_config):
+ # get rid of empty subpartner configs
+ if not subpartner_config:
+ continue
+ # Make sure our locale list is a subset of all_locales
+ pc.setdefault(kind, {}).setdefault(partner, {})[
+ subpartner
+ ] = _fix_subpartner_locales(subpartner_config, all_locales)
+ return pc
+
+
+# seems likely this exists elsewhere already
+def get_ftp_platform(platform):
+ if platform.startswith("win32"):
+ return "win32"
+ elif platform.startswith("win64-aarch64"):
+ return "win64-aarch64"
+ elif platform.startswith("win64"):
+ return "win64"
+ elif platform.startswith("macosx"):
+ return "mac"
+ elif platform.startswith("linux-"):
+ return "linux-i686"
+ elif platform.startswith("linux64"):
+ return "linux-x86_64"
+ else:
+ raise ValueError("Unimplemented platform {}".format(platform))
+
+
+# Ugh
+def locales_per_build_platform(build_platform, locales):
+ if build_platform.startswith("mac"):
+ exclude = ["ja"]
+ else:
+ exclude = ["ja-JP-mac"]
+ return [locale for locale in locales if locale not in exclude]
+
+
+def get_partner_url_config(parameters, graph_config):
+ partner_url_config = deepcopy(graph_config["partner-urls"])
+ substitutions = {
+ "release-product": parameters["release_product"],
+ "release-level": release_level(parameters["project"]),
+ "release-type": parameters["release_type"],
+ }
+ resolve_keyed_by(
+ partner_url_config,
+ "release-eme-free-repack",
+ "eme-free manifest_url",
+ **substitutions
+ )
+ resolve_keyed_by(
+ partner_url_config,
+ "release-partner-repack",
+ "partner manifest url",
+ **substitutions
+ )
+ resolve_keyed_by(
+ partner_url_config,
+ "release-partner-attribution",
+ "partner attribution url",
+ **substitutions
+ )
+ return partner_url_config
+
+
+def get_repack_ids_by_platform(config, build_platform):
+ partner_config = get_partner_config_by_kind(config, config.kind)
+ combinations = []
+ for partner, subconfigs in partner_config.items():
+ for sub_config_name, sub_config in subconfigs.items():
+ if build_platform not in sub_config.get("platforms", []):
+ continue
+ locales = locales_per_build_platform(
+ build_platform, sub_config.get("locales", [])
+ )
+ for locale in locales:
+ combinations.append("{}/{}/{}".format(partner, sub_config_name, locale))
+ return sorted(combinations)
+
+
+def get_partners_to_be_published(config):
+ # hardcoded kind because release-bouncer-aliases doesn't match otherwise
+ partner_config = get_partner_config_by_kind(config, "release-partner-repack")
+ partners = []
+ for partner, subconfigs in partner_config.items():
+ for sub_config_name, sub_config in subconfigs.items():
+ if sub_config.get("publish_to_releases"):
+ partners.append((partner, sub_config_name, sub_config["platforms"]))
+ return partners
+
+
+def apply_partner_priority(config, jobs):
+ priority = None
+ # Reduce the priority of the partner repack jobs because they don't block QE. Meanwhile
+ # leave EME-free jobs alone because they do, and they'll get the branch priority like the rest
+ # of the release. Only bother with this in production, not on staging releases on try.
+ # medium is the same as mozilla-central, see taskcluster/ci/config.yml. ie higher than
+ # integration branches because we don't want to wait a lot for the graph to be done, but
+ # for multiple releases the partner tasks always wait for non-partner.
+ if (
+ config.kind.startswith(
+ ("release-partner-repack", "release-partner-attribution")
+ )
+ and config.params.release_level() == "production"
+ ):
+ priority = "medium"
+ for job in jobs:
+ if priority:
+ job["priority"] = priority
+ yield job
+
+
+def generate_attribution_code(defaults, partner):
+ params = {
+ "medium": defaults["medium"],
+ "source": defaults["source"],
+ "campaign": partner["campaign"],
+ "content": partner["content"],
+ }
+ if partner.get("variation"):
+ params["variation"] = partner["variation"]
+ if partner.get("experiment"):
+ params["experiment"] = partner["experiment"]
+
+ code = six.moves.urllib.parse.urlencode(params)
+ return code
diff --git a/taskcluster/taskgraph/util/perfile.py b/taskcluster/taskgraph/util/perfile.py
new file mode 100644
index 0000000000..5944593e26
--- /dev/null
+++ b/taskcluster/taskgraph/util/perfile.py
@@ -0,0 +1,103 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import itertools
+import json
+import logging
+import math
+
+from mozbuild.util import memoize
+from mozpack.path import match as mozpackmatch
+from taskgraph import files_changed
+import taskgraph
+from .. import GECKO
+
+logger = logging.getLogger(__name__)
+
+
+@memoize
+def perfile_number_of_chunks(is_try, try_task_config, head_repository, head_rev, type):
+ if taskgraph.fast and not is_try:
+ # When iterating on taskgraph changes, the exact number of chunks that
+ # test-verify runs usually isn't important, so skip it when going fast.
+ return 3
+ tests_per_chunk = 10.0
+ if type.startswith("test-coverage"):
+ tests_per_chunk = 30.0
+
+ if type.startswith("test-verify-wpt") or type.startswith("test-coverage-wpt"):
+ file_patterns = [
+ "testing/web-platform/tests/**",
+ "testing/web-platform/mozilla/tests/**",
+ ]
+ elif type.startswith("test-verify-gpu") or type.startswith("test-coverage-gpu"):
+ file_patterns = [
+ "**/*webgl*/**/test_*",
+ "**/dom/canvas/**/test_*",
+ "**/gfx/tests/**/test_*",
+ "**/devtools/canvasdebugger/**/browser_*",
+ "**/reftest*/**",
+ ]
+ elif type.startswith("test-verify") or type.startswith("test-coverage"):
+ file_patterns = [
+ "**/test_*",
+ "**/browser_*",
+ "**/crashtest*/**",
+ "js/src/tests/test/**",
+ "js/src/tests/non262/**",
+ "js/src/tests/test262/**",
+ ]
+ else:
+ # Returning 0 means no tests to run, this captures non test-verify tasks
+ return 1
+
+ changed_files = set()
+ if try_task_config:
+ suite_to_paths = json.loads(try_task_config)
+ specified_files = itertools.chain.from_iterable(suite_to_paths.values())
+ changed_files.update(specified_files)
+
+ if is_try:
+ changed_files.update(files_changed.get_locally_changed_files(GECKO))
+ else:
+ changed_files.update(files_changed.get_changed_files(head_repository, head_rev))
+
+ test_count = 0
+ for pattern in file_patterns:
+ for path in changed_files:
+ # TODO: consider running tests if a manifest changes
+ if path.endswith(".list") or path.endswith(".ini"):
+ continue
+ if path.endswith("^headers^"):
+ continue
+
+ if mozpackmatch(path, pattern):
+ gpu = False
+ if type == "test-verify-e10s" or type == "test-coverage-e10s":
+ # file_patterns for test-verify will pick up some gpu tests, lets ignore
+ # in the case of reftest, we will not have any in the regular case
+ gpu_dirs = [
+ "dom/canvas",
+ "gfx/tests",
+ "devtools/canvasdebugger",
+ "webgl",
+ ]
+ for gdir in gpu_dirs:
+ if len(path.split(gdir)) > 1:
+ gpu = True
+
+ if not gpu:
+ test_count += 1
+
+ chunks = test_count / tests_per_chunk
+ chunks = int(math.ceil(chunks))
+
+ # Never return 0 chunks on try, so that per-file tests can be pushed to try with
+ # an explicit path, and also so "empty" runs can be checked on try.
+ if is_try and chunks == 0:
+ chunks = 1
+
+ return chunks
diff --git a/taskcluster/taskgraph/util/platforms.py b/taskcluster/taskgraph/util/platforms.py
new file mode 100644
index 0000000000..8f68eb8809
--- /dev/null
+++ b/taskcluster/taskgraph/util/platforms.py
@@ -0,0 +1,61 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import re
+
+from .attributes import keymatch
+
+# platform family is extracted from build platform by taking the alphabetic prefix
+# and then translating win -> windows
+_platform_re = re.compile(r"^[a-z]*")
+_renames = {"win": "windows"}
+
+
+_archive_formats = {
+ "linux": ".tar.bz2",
+ "macosx": ".tar.gz",
+ "windows": ".zip",
+}
+
+_executable_extension = {
+ "linux": "",
+ "macosx": "",
+ "windows": ".exe",
+}
+
+_architectures = {
+ r"linux\b.*": "x86",
+ r"linux64\b.*": "x86_64",
+ r"macosx64\b.*": "macos-x86_64-aarch64",
+ r"win32\b.*": "x86",
+ r"win64\b(?!-aarch64).*": "x86_64",
+ r"win64-aarch64\b.*": "aarch64",
+}
+
+
+def platform_family(build_platform):
+ """Given a build platform, return the platform family (linux, macosx, etc.)"""
+ family = _platform_re.match(build_platform).group(0)
+ return _renames.get(family, family)
+
+
+def archive_format(build_platform):
+ """Given a build platform, return the archive format used on the platform."""
+ return _archive_formats[platform_family(build_platform)]
+
+
+def executable_extension(build_platform):
+ """Given a build platform, return the executable extension used on the platform."""
+ return _executable_extension[platform_family(build_platform)]
+
+
+def architecture(build_platform):
+ matches = keymatch(_architectures, build_platform)
+ if len(matches) == 1:
+ return matches[0]
+ raise Exception(
+ "Could not determine architecture of platform `{}`.".format(build_platform)
+ )
diff --git a/taskcluster/taskgraph/util/python_path.py b/taskcluster/taskgraph/util/python_path.py
new file mode 100644
index 0000000000..c8bc11c5dd
--- /dev/null
+++ b/taskcluster/taskgraph/util/python_path.py
@@ -0,0 +1,56 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import inspect
+import os
+
+
+def find_object(path):
+ """
+ Find a Python object given a path of the form <modulepath>:<objectpath>.
+ Conceptually equivalent to
+
+ def find_object(modulepath, objectpath):
+ import <modulepath> as mod
+ return mod.<objectpath>
+ """
+ if path.count(":") != 1:
+ raise ValueError(
+ 'python path {!r} does not have the form "module:object"'.format(path)
+ )
+
+ modulepath, objectpath = path.split(":")
+ obj = __import__(modulepath)
+ for a in modulepath.split(".")[1:]:
+ obj = getattr(obj, a)
+ for a in objectpath.split("."):
+ obj = getattr(obj, a)
+ return obj
+
+
+def import_sibling_modules(exceptions=None):
+ """
+ Import all Python modules that are siblings of the calling module.
+
+ Args:
+ exceptions (list): A list of file names to exclude (caller and
+ __init__.py are implicitly excluded).
+ """
+ frame = inspect.stack()[1]
+ mod = inspect.getmodule(frame[0])
+
+ name = os.path.basename(mod.__file__)
+ excs = set(["__init__.py", name])
+ if exceptions:
+ excs.update(exceptions)
+
+ modpath = mod.__name__
+ if not name.startswith("__init__.py"):
+ modpath = modpath.rsplit(".", 1)[0]
+
+ for f in os.listdir(os.path.dirname(mod.__file__)):
+ if f.endswith(".py") and f not in excs:
+ __import__(modpath + "." + f[:-3])
diff --git a/taskcluster/taskgraph/util/schema.py b/taskcluster/taskgraph/util/schema.py
new file mode 100644
index 0000000000..a095132a94
--- /dev/null
+++ b/taskcluster/taskgraph/util/schema.py
@@ -0,0 +1,228 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import re
+import pprint
+import collections
+import voluptuous
+
+from six import text_type, iteritems
+
+import taskgraph
+
+from .keyed_by import evaluate_keyed_by
+
+
+def validate_schema(schema, obj, msg_prefix):
+ """
+ Validate that object satisfies schema. If not, generate a useful exception
+ beginning with msg_prefix.
+ """
+ if taskgraph.fast:
+ return
+ try:
+ schema(obj)
+ except voluptuous.MultipleInvalid as exc:
+ msg = [msg_prefix]
+ for error in exc.errors:
+ msg.append(str(error))
+ raise Exception("\n".join(msg) + "\n" + pprint.pformat(obj))
+
+
+def optionally_keyed_by(*arguments):
+ """
+ Mark a schema value as optionally keyed by any of a number of fields. The
+ schema is the last argument, and the remaining fields are taken to be the
+ field names. For example:
+
+ 'some-value': optionally_keyed_by(
+ 'test-platform', 'build-platform',
+ Any('a', 'b', 'c'))
+
+ The resulting schema will allow nesting of `by-test-platform` and
+ `by-build-platform` in either order.
+ """
+ schema = arguments[-1]
+ fields = arguments[:-1]
+
+ def validator(obj):
+ if isinstance(obj, dict) and len(obj) == 1:
+ k, v = list(obj.items())[0]
+ if k.startswith("by-") and k[len("by-") :] in fields:
+ res = {}
+ for kk, vv in v.items():
+ try:
+ res[kk] = validator(vv)
+ except voluptuous.Invalid as e:
+ e.prepend([k, kk])
+ raise
+ return res
+ return Schema(schema)(obj)
+
+ return validator
+
+
+def resolve_keyed_by(item, field, item_name, defer=None, **extra_values):
+ """
+ For values which can either accept a literal value, or be keyed by some
+ other attribute of the item, perform that lookup and replacement in-place
+ (modifying `item` directly). The field is specified using dotted notation
+ to traverse dictionaries.
+
+ For example, given item::
+
+ job:
+ test-platform: linux128
+ chunks:
+ by-test-platform:
+ macosx-10.11/debug: 13
+ win.*: 6
+ default: 12
+
+ a call to `resolve_keyed_by(item, 'job.chunks', item['thing-name'])`
+ would mutate item in-place to::
+
+ job:
+ test-platform: linux128
+ chunks: 12
+
+ The `item_name` parameter is used to generate useful error messages.
+
+ If extra_values are supplied, they represent additional values available
+ for reference from by-<field>.
+
+ Items can be nested as deeply as the schema will allow::
+
+ chunks:
+ by-test-platform:
+ win.*:
+ by-project:
+ ash: ..
+ cedar: ..
+ linux: 13
+ default: 12
+
+ The `defer` parameter allows evaluating a by-* entry at a later time. In the
+ example above it's possible that the project attribute hasn't been set
+ yet, in which case we'd want to stop before resolving that subkey and then
+ call this function again later. This can be accomplished by setting
+ `defer=["project"]` in this example.
+ """
+ # find the field, returning the item unchanged if anything goes wrong
+ container, subfield = item, field
+ while "." in subfield:
+ f, subfield = subfield.split(".", 1)
+ if f not in container:
+ return item
+ container = container[f]
+ if not isinstance(container, dict):
+ return item
+
+ if subfield not in container:
+ return item
+
+ container[subfield] = evaluate_keyed_by(
+ value=container[subfield],
+ item_name="`{}` in `{}`".format(field, item_name),
+ defer=defer,
+ attributes=dict(item, **extra_values),
+ )
+
+ return item
+
+
+# Schemas for YAML files should use dashed identifiers by default. If there are
+# components of the schema for which there is a good reason to use another format,
+# they can be whitelisted here.
+WHITELISTED_SCHEMA_IDENTIFIERS = [
+ # upstream-artifacts are handed directly to scriptWorker, which expects interCaps
+ lambda path: "[{!r}]".format("upstream-artifacts") in path,
+ lambda path: (
+ "[{!r}]".format("test_name") in path
+ or "[{!r}]".format("json_location") in path
+ or "[{!r}]".format("video_location") in path
+ ),
+]
+
+
+def check_schema(schema):
+ identifier_re = re.compile("^[a-z][a-z0-9-]*$")
+
+ def whitelisted(path):
+ return any(f(path) for f in WHITELISTED_SCHEMA_IDENTIFIERS)
+
+ def iter(path, sch):
+ def check_identifier(path, k):
+ if k in (text_type, text_type, voluptuous.Extra):
+ pass
+ elif isinstance(k, voluptuous.NotIn):
+ pass
+ elif isinstance(k, text_type):
+ if not identifier_re.match(k) and not whitelisted(path):
+ raise RuntimeError(
+ "YAML schemas should use dashed lower-case identifiers, "
+ "not {!r} @ {}".format(k, path)
+ )
+ elif isinstance(k, (voluptuous.Optional, voluptuous.Required)):
+ check_identifier(path, k.schema)
+ elif isinstance(k, (voluptuous.Any, voluptuous.All)):
+ for v in k.validators:
+ check_identifier(path, v)
+ elif not whitelisted(path):
+ raise RuntimeError(
+ "Unexpected type in YAML schema: {} @ {}".format(
+ type(k).__name__, path
+ )
+ )
+
+ if isinstance(sch, collections.Mapping):
+ for k, v in iteritems(sch):
+ child = "{}[{!r}]".format(path, k)
+ check_identifier(child, k)
+ iter(child, v)
+ elif isinstance(sch, (list, tuple)):
+ for i, v in enumerate(sch):
+ iter("{}[{}]".format(path, i), v)
+ elif isinstance(sch, voluptuous.Any):
+ for v in sch.validators:
+ iter(path, v)
+
+ iter("schema", schema.schema)
+
+
+class Schema(voluptuous.Schema):
+ """
+ Operates identically to voluptuous.Schema, but applying some taskgraph-specific checks
+ in the process.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super(Schema, self).__init__(*args, **kwargs)
+ if not taskgraph.fast:
+ check_schema(self)
+
+ def extend(self, *args, **kwargs):
+ schema = super(Schema, self).extend(*args, **kwargs)
+ check_schema(schema)
+ # We want twice extend schema to be checked too.
+ schema.__class__ = Schema
+ return schema
+
+ def _compile(self, schema):
+ if taskgraph.fast:
+ return
+ return super(Schema, self)._compile(schema)
+
+ def __getitem__(self, item):
+ return self.schema[item]
+
+
+# shortcut for a string where task references are allowed
+taskref_or_string = voluptuous.Any(
+ text_type,
+ {voluptuous.Required("task-reference"): text_type},
+ {voluptuous.Required("artifact-reference"): text_type},
+)
diff --git a/taskcluster/taskgraph/util/scriptworker.py b/taskcluster/taskgraph/util/scriptworker.py
new file mode 100644
index 0000000000..af94432295
--- /dev/null
+++ b/taskcluster/taskgraph/util/scriptworker.py
@@ -0,0 +1,819 @@
+# 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/.
+"""Make scriptworker.cot.verify more user friendly by making scopes dynamic.
+
+Scriptworker uses certain scopes to determine which sets of credentials to use.
+Certain scopes are restricted by branch in chain of trust verification, and are
+checked again at the script level. This file provides functions to adjust
+these scopes automatically by project; this makes pushing to try, forking a
+project branch, and merge day uplifts more user friendly.
+
+In the future, we may adjust scopes by other settings as well, e.g. different
+scopes for `push-to-candidates` rather than `push-to-releases`, even if both
+happen on mozilla-beta and mozilla-release.
+
+Additional configuration is found in the :ref:`graph config <taskgraph-graph-config>`.
+"""
+from __future__ import absolute_import, print_function, unicode_literals
+import functools
+import json
+import os
+import itertools
+from copy import deepcopy
+from datetime import datetime
+
+import jsone
+
+from mozbuild.util import memoize
+
+from .schema import resolve_keyed_by
+from .taskcluster import get_artifact_prefix
+from .yaml import load_yaml
+
+# constants {{{1
+"""Map signing scope aliases to sets of projects.
+
+Currently m-c and DevEdition on m-b use nightly signing; Beta on m-b and m-r
+use release signing. These data structures aren't set-up to handle different
+scopes on the same repo, so we use a different set of them for DevEdition, and
+callers are responsible for using the correct one (by calling the appropriate
+helper below). More context on this in https://bugzilla.mozilla.org/show_bug.cgi?id=1358601.
+
+We will need to add esr support at some point. Eventually we want to add
+nuance so certain m-b and m-r tasks use dep or nightly signing, and we only
+release sign when we have a signed-off set of candidate builds. This current
+approach works for now, though.
+
+This is a list of list-pairs, for ordering.
+"""
+SIGNING_SCOPE_ALIAS_TO_PROJECT = [
+ [
+ "all-nightly-branches",
+ set(
+ [
+ "mozilla-central",
+ "comm-central",
+ "oak",
+ ]
+ ),
+ ],
+ [
+ "all-release-branches",
+ set(
+ [
+ "mozilla-beta",
+ "mozilla-release",
+ "mozilla-esr78",
+ "comm-beta",
+ "comm-esr78",
+ ]
+ ),
+ ],
+]
+
+"""Map the signing scope aliases to the actual scopes.
+"""
+SIGNING_CERT_SCOPES = {
+ "all-release-branches": "signing:cert:release-signing",
+ "all-nightly-branches": "signing:cert:nightly-signing",
+ "default": "signing:cert:dep-signing",
+}
+
+DEVEDITION_SIGNING_SCOPE_ALIAS_TO_PROJECT = [
+ [
+ "beta",
+ set(
+ [
+ "mozilla-beta",
+ ]
+ ),
+ ]
+]
+
+DEVEDITION_SIGNING_CERT_SCOPES = {
+ "beta": "signing:cert:nightly-signing",
+ "default": "signing:cert:dep-signing",
+}
+
+"""Map beetmover scope aliases to sets of projects.
+"""
+BEETMOVER_SCOPE_ALIAS_TO_PROJECT = [
+ [
+ "all-nightly-branches",
+ set(
+ [
+ "mozilla-central",
+ "comm-central",
+ "oak",
+ ]
+ ),
+ ],
+ [
+ "all-release-branches",
+ set(
+ [
+ "mozilla-beta",
+ "mozilla-release",
+ "mozilla-esr78",
+ "comm-beta",
+ "comm-esr78",
+ ]
+ ),
+ ],
+]
+
+"""Map the beetmover scope aliases to the actual scopes.
+"""
+BEETMOVER_BUCKET_SCOPES = {
+ "all-release-branches": "beetmover:bucket:release",
+ "all-nightly-branches": "beetmover:bucket:nightly",
+ "default": "beetmover:bucket:dep",
+}
+
+"""Map the beetmover tasks aliases to the actual action scopes.
+"""
+BEETMOVER_ACTION_SCOPES = {
+ "nightly": "beetmover:action:push-to-nightly",
+ "nightly-oak": "beetmover:action:push-to-nightly",
+ "default": "beetmover:action:push-to-candidates",
+}
+
+
+"""Known balrog actions."""
+BALROG_ACTIONS = (
+ "submit-locale",
+ "submit-toplevel",
+ "schedule",
+ "v2-submit-locale",
+ "v2-submit-toplevel",
+)
+
+"""Map balrog scope aliases to sets of projects.
+
+This is a list of list-pairs, for ordering.
+"""
+BALROG_SCOPE_ALIAS_TO_PROJECT = [
+ [
+ "nightly",
+ set(
+ [
+ "mozilla-central",
+ "comm-central",
+ "oak",
+ ]
+ ),
+ ],
+ [
+ "beta",
+ set(
+ [
+ "mozilla-beta",
+ "comm-beta",
+ ]
+ ),
+ ],
+ [
+ "release",
+ set(
+ [
+ "mozilla-release",
+ "comm-esr78",
+ ]
+ ),
+ ],
+ [
+ "esr78",
+ set(
+ [
+ "mozilla-esr78",
+ ]
+ ),
+ ],
+]
+
+"""Map the balrog scope aliases to the actual scopes.
+"""
+BALROG_SERVER_SCOPES = {
+ "nightly": "balrog:server:nightly",
+ "aurora": "balrog:server:aurora",
+ "beta": "balrog:server:beta",
+ "release": "balrog:server:release",
+ "esr78": "balrog:server:esr",
+ "default": "balrog:server:dep",
+}
+
+
+""" The list of the release promotion phases which we send notifications for
+"""
+RELEASE_NOTIFICATION_PHASES = ("promote", "push", "ship")
+
+
+def add_scope_prefix(config, scope):
+ """
+ Prepends the scriptworker scope prefix from the :ref:`graph config
+ <taskgraph-graph-config>`.
+
+ Args:
+ config (TransformConfig): The configuration for the kind being transformed.
+ scope (string): The suffix of the scope
+
+ Returns:
+ string: the scope to use.
+ """
+ return "{prefix}:{scope}".format(
+ prefix=config.graph_config["scriptworker"]["scope-prefix"],
+ scope=scope,
+ )
+
+
+def with_scope_prefix(f):
+ """
+ Wraps a function, calling :py:func:`add_scope_prefix` on the result of
+ calling the wrapped function.
+
+ Args:
+ f (callable): A function that takes a ``config`` and some keyword
+ arguments, and returns a scope suffix.
+
+ Returns:
+ callable: the wrapped function
+ """
+
+ @functools.wraps(f)
+ def wrapper(config, **kwargs):
+ scope_or_scopes = f(config, **kwargs)
+ if isinstance(scope_or_scopes, list):
+ return map(functools.partial(add_scope_prefix, config), scope_or_scopes)
+ else:
+ return add_scope_prefix(config, scope_or_scopes)
+
+ return wrapper
+
+
+# scope functions {{{1
+@with_scope_prefix
+def get_scope_from_project(config, alias_to_project_map, alias_to_scope_map):
+ """Determine the restricted scope from `config.params['project']`.
+
+ Args:
+ config (TransformConfig): The configuration for the kind being transformed.
+ alias_to_project_map (list of lists): each list pair contains the
+ alias and the set of projects that match. This is ordered.
+ alias_to_scope_map (dict): the alias alias to scope
+
+ Returns:
+ string: the scope to use.
+ """
+ for alias, projects in alias_to_project_map:
+ if config.params["project"] in projects and alias in alias_to_scope_map:
+ return alias_to_scope_map[alias]
+ return alias_to_scope_map["default"]
+
+
+@with_scope_prefix
+def get_scope_from_release_type(config, release_type_to_scope_map):
+ """Determine the restricted scope from `config.params['target_tasks_method']`.
+
+ Args:
+ config (TransformConfig): The configuration for the kind being transformed.
+ release_type_to_scope_map (dict): the maps release types to scopes
+
+ Returns:
+ string: the scope to use.
+ """
+ return release_type_to_scope_map.get(
+ config.params["release_type"], release_type_to_scope_map["default"]
+ )
+
+
+def get_phase_from_target_method(config, alias_to_tasks_map, alias_to_phase_map):
+ """Determine the phase from `config.params['target_tasks_method']`.
+
+ Args:
+ config (TransformConfig): The configuration for the kind being transformed.
+ alias_to_tasks_map (list of lists): each list pair contains the
+ alias and the set of target methods that match. This is ordered.
+ alias_to_phase_map (dict): the alias to phase map
+
+ Returns:
+ string: the phase to use.
+ """
+ for alias, tasks in alias_to_tasks_map:
+ if (
+ config.params["target_tasks_method"] in tasks
+ and alias in alias_to_phase_map
+ ):
+ return alias_to_phase_map[alias]
+ return alias_to_phase_map["default"]
+
+
+@with_scope_prefix
+def get_balrog_action_scope(config, action="submit"):
+ assert action in BALROG_ACTIONS
+ return "balrog:action:{}".format(action)
+
+
+get_signing_cert_scope = functools.partial(
+ get_scope_from_project,
+ alias_to_project_map=SIGNING_SCOPE_ALIAS_TO_PROJECT,
+ alias_to_scope_map=SIGNING_CERT_SCOPES,
+)
+
+get_devedition_signing_cert_scope = functools.partial(
+ get_scope_from_project,
+ alias_to_project_map=DEVEDITION_SIGNING_SCOPE_ALIAS_TO_PROJECT,
+ alias_to_scope_map=DEVEDITION_SIGNING_CERT_SCOPES,
+)
+
+get_beetmover_bucket_scope = functools.partial(
+ get_scope_from_project,
+ alias_to_project_map=BEETMOVER_SCOPE_ALIAS_TO_PROJECT,
+ alias_to_scope_map=BEETMOVER_BUCKET_SCOPES,
+)
+
+get_beetmover_action_scope = functools.partial(
+ get_scope_from_release_type,
+ release_type_to_scope_map=BEETMOVER_ACTION_SCOPES,
+)
+
+get_balrog_server_scope = functools.partial(
+ get_scope_from_project,
+ alias_to_project_map=BALROG_SCOPE_ALIAS_TO_PROJECT,
+ alias_to_scope_map=BALROG_SERVER_SCOPES,
+)
+
+cached_load_yaml = memoize(load_yaml)
+
+
+# release_config {{{1
+def get_release_config(config):
+ """Get the build number and version for a release task.
+
+ Currently only applies to beetmover tasks.
+
+ Args:
+ config (TransformConfig): The configuration for the kind being transformed.
+
+ Returns:
+ dict: containing both `build_number` and `version`. This can be used to
+ update `task.payload`.
+ """
+ release_config = {}
+
+ partial_updates = os.environ.get("PARTIAL_UPDATES", "")
+ if partial_updates != "" and config.kind in (
+ "release-bouncer-sub",
+ "release-bouncer-check",
+ "release-update-verify-config",
+ "release-secondary-update-verify-config",
+ "release-balrog-submit-toplevel",
+ "release-secondary-balrog-submit-toplevel",
+ ):
+ partial_updates = json.loads(partial_updates)
+ release_config["partial_versions"] = ", ".join(
+ [
+ "{}build{}".format(v, info["buildNumber"])
+ for v, info in partial_updates.items()
+ ]
+ )
+ if release_config["partial_versions"] == "{}":
+ del release_config["partial_versions"]
+
+ release_config["version"] = config.params["version"]
+ release_config["appVersion"] = config.params["app_version"]
+
+ release_config["next_version"] = config.params["next_version"]
+ release_config["build_number"] = config.params["build_number"]
+ return release_config
+
+
+def get_signing_cert_scope_per_platform(build_platform, is_shippable, config):
+ if "devedition" in build_platform:
+ return get_devedition_signing_cert_scope(config)
+ elif is_shippable:
+ return get_signing_cert_scope(config)
+ else:
+ return add_scope_prefix(config, "signing:cert:dep-signing")
+
+
+# generate_beetmover_upstream_artifacts {{{1
+def generate_beetmover_upstream_artifacts(
+ config, job, platform, locale=None, dependencies=None, **kwargs
+):
+ """Generate the upstream artifacts for beetmover, using the artifact map.
+
+ Currently only applies to beetmover tasks.
+
+ Args:
+ job (dict): The current job being generated
+ dependencies (list): A list of the job's dependency labels.
+ platform (str): The current build platform
+ locale (str): The current locale being beetmoved.
+
+ Returns:
+ list: A list of dictionaries conforming to the upstream_artifacts spec.
+ """
+ base_artifact_prefix = get_artifact_prefix(job)
+ resolve_keyed_by(
+ job,
+ "attributes.artifact_map",
+ "artifact map",
+ **{
+ "release-type": config.params["release_type"],
+ "platform": platform,
+ }
+ )
+ map_config = deepcopy(cached_load_yaml(job["attributes"]["artifact_map"]))
+ upstream_artifacts = list()
+
+ if not locale:
+ locales = map_config["default_locales"]
+ elif isinstance(locale, list):
+ locales = locale
+ else:
+ locales = [locale]
+
+ if not dependencies:
+ if job.get("dependencies"):
+ dependencies = job["dependencies"].keys()
+ elif job.get("primary-dependency"):
+ dependencies = [job["primary-dependency"].kind]
+ else:
+ raise Exception("Unsupported type of dependency. Got job: {}".format(job))
+
+ for locale, dep in itertools.product(locales, dependencies):
+ paths = list()
+
+ for filename in map_config["mapping"]:
+ if dep not in map_config["mapping"][filename]["from"]:
+ continue
+ if locale != "en-US" and not map_config["mapping"][filename]["all_locales"]:
+ continue
+ if (
+ "only_for_platforms" in map_config["mapping"][filename]
+ and platform
+ not in map_config["mapping"][filename]["only_for_platforms"]
+ ):
+ continue
+ if (
+ "not_for_platforms" in map_config["mapping"][filename]
+ and platform in map_config["mapping"][filename]["not_for_platforms"]
+ ):
+ continue
+ if "partials_only" in map_config["mapping"][filename]:
+ continue
+ # The next time we look at this file it might be a different locale.
+ file_config = deepcopy(map_config["mapping"][filename])
+ resolve_keyed_by(
+ file_config,
+ "source_path_modifier",
+ "source path modifier",
+ locale=locale,
+ )
+
+ kwargs["locale"] = locale
+
+ paths.append(
+ os.path.join(
+ base_artifact_prefix,
+ jsone.render(file_config["source_path_modifier"], kwargs),
+ jsone.render(filename, kwargs),
+ )
+ )
+
+ if job.get("dependencies") and getattr(
+ job["dependencies"][dep], "release_artifacts", None
+ ):
+ paths = [
+ path
+ for path in paths
+ if path in job["dependencies"][dep].release_artifacts
+ ]
+
+ if not paths:
+ continue
+
+ upstream_artifacts.append(
+ {
+ "taskId": {"task-reference": "<{}>".format(dep)},
+ "taskType": map_config["tasktype_map"].get(dep),
+ "paths": sorted(paths),
+ "locale": locale,
+ }
+ )
+
+ upstream_artifacts.sort(key=lambda u: u["paths"])
+ return upstream_artifacts
+
+
+# generate_beetmover_artifact_map {{{1
+def generate_beetmover_artifact_map(config, job, **kwargs):
+ """Generate the beetmover artifact map.
+
+ Currently only applies to beetmover tasks.
+
+ Args:
+ config (): Current taskgraph configuration.
+ job (dict): The current job being generated
+ Common kwargs:
+ platform (str): The current build platform
+ locale (str): The current locale being beetmoved.
+
+ Returns:
+ list: A list of dictionaries containing source->destination
+ maps for beetmover.
+ """
+ platform = kwargs.get("platform", "")
+ resolve_keyed_by(
+ job,
+ "attributes.artifact_map",
+ job["label"],
+ **{
+ "release-type": config.params["release_type"],
+ "platform": platform,
+ }
+ )
+ map_config = deepcopy(cached_load_yaml(job["attributes"]["artifact_map"]))
+ base_artifact_prefix = map_config.get(
+ "base_artifact_prefix", get_artifact_prefix(job)
+ )
+
+ artifacts = list()
+
+ dependencies = job["dependencies"].keys()
+
+ if kwargs.get("locale"):
+ if isinstance(kwargs["locale"], list):
+ locales = kwargs["locale"]
+ else:
+ locales = [kwargs["locale"]]
+ else:
+ locales = map_config["default_locales"]
+
+ resolve_keyed_by(map_config, "s3_bucket_paths", job["label"], platform=platform)
+
+ for locale, dep in sorted(itertools.product(locales, dependencies)):
+ paths = dict()
+ for filename in map_config["mapping"]:
+ # Relevancy checks
+ if dep not in map_config["mapping"][filename]["from"]:
+ # We don't get this file from this dependency.
+ continue
+ if locale != "en-US" and not map_config["mapping"][filename]["all_locales"]:
+ # This locale either doesn't produce or shouldn't upload this file.
+ continue
+ if (
+ "only_for_platforms" in map_config["mapping"][filename]
+ and platform
+ not in map_config["mapping"][filename]["only_for_platforms"]
+ ):
+ # This platform either doesn't produce or shouldn't upload this file.
+ continue
+ if (
+ "not_for_platforms" in map_config["mapping"][filename]
+ and platform in map_config["mapping"][filename]["not_for_platforms"]
+ ):
+ # This platform either doesn't produce or shouldn't upload this file.
+ continue
+ if "partials_only" in map_config["mapping"][filename]:
+ continue
+
+ # deepcopy because the next time we look at this file the locale will differ.
+ file_config = deepcopy(map_config["mapping"][filename])
+
+ for field in [
+ "destinations",
+ "locale_prefix",
+ "source_path_modifier",
+ "update_balrog_manifest",
+ "pretty_name",
+ "checksums_path",
+ ]:
+ resolve_keyed_by(
+ file_config, field, job["label"], locale=locale, platform=platform
+ )
+
+ # This format string should ideally be in the configuration file,
+ # but this would mean keeping variable names in sync between code + config.
+ destinations = [
+ "{s3_bucket_path}/{dest_path}/{locale_prefix}{filename}".format(
+ s3_bucket_path=bucket_path,
+ dest_path=dest_path,
+ locale_prefix=file_config["locale_prefix"],
+ filename=file_config.get("pretty_name", filename),
+ )
+ for dest_path, bucket_path in itertools.product(
+ file_config["destinations"], map_config["s3_bucket_paths"]
+ )
+ ]
+ # Creating map entries
+ # Key must be artifact path, to avoid trampling duplicates, such
+ # as public/build/target.apk and public/build/en-US/target.apk
+ key = os.path.join(
+ base_artifact_prefix,
+ file_config["source_path_modifier"],
+ filename,
+ )
+
+ paths[key] = {
+ "destinations": destinations,
+ }
+ if file_config.get("checksums_path"):
+ paths[key]["checksums_path"] = file_config["checksums_path"]
+
+ # optional flag: balrog manifest
+ if file_config.get("update_balrog_manifest"):
+ paths[key]["update_balrog_manifest"] = True
+ if file_config.get("balrog_format"):
+ paths[key]["balrog_format"] = file_config["balrog_format"]
+
+ if not paths:
+ # No files for this dependency/locale combination.
+ continue
+
+ # Render all variables for the artifact map
+ platforms = deepcopy(map_config.get("platform_names", {}))
+ if platform:
+ for key in platforms.keys():
+ resolve_keyed_by(platforms, key, job["label"], platform=platform)
+
+ upload_date = datetime.fromtimestamp(config.params["build_date"])
+
+ kwargs.update(
+ {
+ "locale": locale,
+ "version": config.params["version"],
+ "branch": config.params["project"],
+ "build_number": config.params["build_number"],
+ "year": upload_date.year,
+ "month": upload_date.strftime("%m"), # zero-pad the month
+ "upload_date": upload_date.strftime("%Y-%m-%d-%H-%M-%S"),
+ }
+ )
+ kwargs.update(**platforms)
+ paths = jsone.render(paths, kwargs)
+ artifacts.append(
+ {
+ "taskId": {"task-reference": "<{}>".format(dep)},
+ "locale": locale,
+ "paths": paths,
+ }
+ )
+
+ return artifacts
+
+
+# generate_beetmover_partials_artifact_map {{{1
+def generate_beetmover_partials_artifact_map(config, job, partials_info, **kwargs):
+ """Generate the beetmover partials artifact map.
+
+ Currently only applies to beetmover tasks.
+
+ Args:
+ config (): Current taskgraph configuration.
+ job (dict): The current job being generated
+ partials_info (dict): Current partials and information about them in a dict
+ Common kwargs:
+ platform (str): The current build platform
+ locale (str): The current locale being beetmoved.
+
+ Returns:
+ list: A list of dictionaries containing source->destination
+ maps for beetmover.
+ """
+ platform = kwargs.get("platform", "")
+ resolve_keyed_by(
+ job,
+ "attributes.artifact_map",
+ "artifact map",
+ **{
+ "release-type": config.params["release_type"],
+ "platform": platform,
+ }
+ )
+ map_config = deepcopy(cached_load_yaml(job["attributes"]["artifact_map"]))
+ base_artifact_prefix = map_config.get(
+ "base_artifact_prefix", get_artifact_prefix(job)
+ )
+
+ artifacts = list()
+ dependencies = job["dependencies"].keys()
+
+ if kwargs.get("locale"):
+ locales = [kwargs["locale"]]
+ else:
+ locales = map_config["default_locales"]
+
+ resolve_keyed_by(
+ map_config, "s3_bucket_paths", "s3_bucket_paths", platform=platform
+ )
+
+ platforms = deepcopy(map_config.get("platform_names", {}))
+ if platform:
+ for key in platforms.keys():
+ resolve_keyed_by(platforms, key, key, platform=platform)
+ upload_date = datetime.fromtimestamp(config.params["build_date"])
+
+ for locale, dep in itertools.product(locales, dependencies):
+ paths = dict()
+ for filename in map_config["mapping"]:
+ # Relevancy checks
+ if dep not in map_config["mapping"][filename]["from"]:
+ # We don't get this file from this dependency.
+ continue
+ if locale != "en-US" and not map_config["mapping"][filename]["all_locales"]:
+ # This locale either doesn't produce or shouldn't upload this file.
+ continue
+ if "partials_only" not in map_config["mapping"][filename]:
+ continue
+ # deepcopy because the next time we look at this file the locale will differ.
+ file_config = deepcopy(map_config["mapping"][filename])
+
+ for field in [
+ "destinations",
+ "locale_prefix",
+ "source_path_modifier",
+ "update_balrog_manifest",
+ "from_buildid",
+ "pretty_name",
+ "checksums_path",
+ ]:
+ resolve_keyed_by(
+ file_config, field, field, locale=locale, platform=platform
+ )
+
+ # This format string should ideally be in the configuration file,
+ # but this would mean keeping variable names in sync between code + config.
+ destinations = [
+ "{s3_bucket_path}/{dest_path}/{locale_prefix}{filename}".format(
+ s3_bucket_path=bucket_path,
+ dest_path=dest_path,
+ locale_prefix=file_config["locale_prefix"],
+ filename=file_config.get("pretty_name", filename),
+ )
+ for dest_path, bucket_path in itertools.product(
+ file_config["destinations"], map_config["s3_bucket_paths"]
+ )
+ ]
+ # Creating map entries
+ # Key must be artifact path, to avoid trampling duplicates, such
+ # as public/build/target.apk and public/build/en-US/target.apk
+ key = os.path.join(
+ base_artifact_prefix,
+ file_config["source_path_modifier"],
+ filename,
+ )
+ partials_paths = {}
+ for pname, info in partials_info.items():
+ partials_paths[key] = {
+ "destinations": destinations,
+ }
+ if file_config.get("checksums_path"):
+ partials_paths[key]["checksums_path"] = file_config[
+ "checksums_path"
+ ]
+
+ # optional flag: balrog manifest
+ if file_config.get("update_balrog_manifest"):
+ partials_paths[key]["update_balrog_manifest"] = True
+ if file_config.get("balrog_format"):
+ partials_paths[key]["balrog_format"] = file_config[
+ "balrog_format"
+ ]
+ # optional flag: from_buildid
+ if file_config.get("from_buildid"):
+ partials_paths[key]["from_buildid"] = file_config["from_buildid"]
+
+ # render buildid
+ kwargs.update(
+ {
+ "partial": pname,
+ "from_buildid": info["buildid"],
+ "previous_version": info.get("previousVersion"),
+ "buildid": str(config.params["moz_build_date"]),
+ "locale": locale,
+ "version": config.params["version"],
+ "branch": config.params["project"],
+ "build_number": config.params["build_number"],
+ "year": upload_date.year,
+ "month": upload_date.strftime("%m"), # zero-pad the month
+ "upload_date": upload_date.strftime("%Y-%m-%d-%H-%M-%S"),
+ }
+ )
+ kwargs.update(**platforms)
+ paths.update(jsone.render(partials_paths, kwargs))
+
+ if not paths:
+ continue
+
+ artifacts.append(
+ {
+ "taskId": {"task-reference": "<{}>".format(dep)},
+ "locale": locale,
+ "paths": paths,
+ }
+ )
+
+ artifacts.sort(key=lambda a: sorted(a["paths"].items()))
+ return artifacts
diff --git a/taskcluster/taskgraph/util/signed_artifacts.py b/taskcluster/taskgraph/util/signed_artifacts.py
new file mode 100644
index 0000000000..0a215e152e
--- /dev/null
+++ b/taskcluster/taskgraph/util/signed_artifacts.py
@@ -0,0 +1,196 @@
+# 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/.
+"""
+Defines artifacts to sign before repackage.
+"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+from taskgraph.util.taskcluster import get_artifact_path
+from taskgraph.util.declarative_artifacts import get_geckoview_upstream_artifacts
+
+
+LANGPACK_SIGN_PLATFORMS = { # set
+ "linux64-shippable",
+ "linux64-devedition",
+ "macosx64-shippable",
+ "macosx64-devedition",
+}
+
+
+def is_partner_kind(kind):
+ if kind and kind.startswith(("release-partner", "release-eme-free")):
+ return True
+
+
+def is_notarization_kind(kind):
+ if kind and "notarization" in kind:
+ return True
+
+
+def generate_specifications_of_artifacts_to_sign(
+ config, job, keep_locale_template=True, kind=None, dep_kind=None
+):
+ build_platform = job["attributes"].get("build_platform")
+ use_stub = job["attributes"].get("stub-installer")
+ # Get locales to know if we want to sign ja-JP-mac langpack
+ locales = job["attributes"].get("chunk_locales", [])
+ if kind == "release-source-signing":
+ artifacts_specifications = [
+ {
+ "artifacts": [get_artifact_path(job, "source.tar.xz")],
+ "formats": ["autograph_gpg"],
+ }
+ ]
+ elif "android" in build_platform:
+ artifacts_specifications = [
+ {
+ "artifacts": get_geckoview_artifacts_to_sign(config, job),
+ "formats": ["autograph_gpg"],
+ }
+ ]
+ # XXX: Mars aren't signed here (on any platform) because internals will be
+ # signed at after this stage of the release
+ elif "macosx" in build_platform:
+ if is_notarization_kind(dep_kind):
+ # This task is notarization part 3: download signed bits,
+ # and staple notarization.
+ artifacts_specifications = [
+ {
+ "artifacts": [
+ get_artifact_path(job, "{locale}/target.tar.gz"),
+ get_artifact_path(job, "{locale}/target.pkg"),
+ ],
+ "formats": [],
+ }
+ ]
+ langpack_formats = []
+ else:
+ # This task is either depsigning, or notarization part 1:
+ # download unsigned bits, and sign. If notarization part 1,
+ # submit for notarization and create a uuid_manifest.json
+ if is_partner_kind(kind):
+ extension = "tar.gz"
+ else:
+ extension = "dmg"
+ artifacts_specifications = [
+ {
+ "artifacts": [
+ get_artifact_path(job, "{{locale}}/target.{}".format(extension))
+ ],
+ "formats": ["macapp", "autograph_widevine", "autograph_omnija"],
+ }
+ ]
+ langpack_formats = ["autograph_langpack"]
+
+ if "ja-JP-mac" in locales and build_platform in LANGPACK_SIGN_PLATFORMS:
+ artifacts_specifications += [
+ {
+ "artifacts": [
+ get_artifact_path(job, "ja-JP-mac/target.langpack.xpi")
+ ],
+ "formats": langpack_formats,
+ }
+ ]
+ elif "win" in build_platform:
+ artifacts_specifications = [
+ {
+ "artifacts": [
+ get_artifact_path(job, "{locale}/setup.exe"),
+ ],
+ "formats": ["autograph_authenticode"],
+ },
+ {
+ "artifacts": [
+ get_artifact_path(job, "{locale}/target.zip"),
+ ],
+ "formats": [
+ "autograph_authenticode",
+ "autograph_widevine",
+ "autograph_omnija",
+ ],
+ },
+ ]
+
+ if use_stub:
+ artifacts_specifications[0]["artifacts"] += [
+ get_artifact_path(job, "{locale}/setup-stub.exe")
+ ]
+ elif "linux" in build_platform:
+ artifacts_specifications = [
+ {
+ "artifacts": [get_artifact_path(job, "{locale}/target.tar.bz2")],
+ "formats": ["autograph_gpg", "autograph_widevine", "autograph_omnija"],
+ }
+ ]
+ if build_platform in LANGPACK_SIGN_PLATFORMS:
+ artifacts_specifications += [
+ {
+ "artifacts": [
+ get_artifact_path(job, "{locale}/target.langpack.xpi")
+ ],
+ "formats": ["autograph_langpack"],
+ }
+ ]
+ else:
+ raise Exception("Platform not implemented for signing")
+
+ if not keep_locale_template:
+ artifacts_specifications = _strip_locale_template(artifacts_specifications)
+
+ if is_partner_kind(kind):
+ artifacts_specifications = _strip_widevine_for_partners(
+ artifacts_specifications
+ )
+
+ return artifacts_specifications
+
+
+def _strip_locale_template(artifacts_without_locales):
+ for spec in artifacts_without_locales:
+ for index, artifact in enumerate(spec["artifacts"]):
+ stripped_artifact = artifact.format(locale="")
+ stripped_artifact = stripped_artifact.replace("//", "/")
+ spec["artifacts"][index] = stripped_artifact
+
+ return artifacts_without_locales
+
+
+def _strip_widevine_for_partners(artifacts_specifications):
+ """Partner repacks should not resign that's previously signed for fear of breaking partial
+ updates
+ """
+ for spec in artifacts_specifications:
+ if "autograph_widevine" in spec["formats"]:
+ spec["formats"].remove("autograph_widevine")
+ if "autograph_omnija" in spec["formats"]:
+ spec["formats"].remove("autograph_omnija")
+
+ return artifacts_specifications
+
+
+def get_signed_artifacts(input, formats, behavior=None):
+ """
+ Get the list of signed artifacts for the given input and formats.
+ """
+ artifacts = set()
+ if input.endswith(".dmg"):
+ artifacts.add(input.replace(".dmg", ".tar.gz"))
+ if behavior and behavior != "mac_sign":
+ artifacts.add(input.replace(".dmg", ".pkg"))
+ else:
+ artifacts.add(input)
+ if "autograph_gpg" in formats:
+ artifacts.add("{}.asc".format(input))
+
+ return artifacts
+
+
+def get_geckoview_artifacts_to_sign(config, job):
+ upstream_artifacts = get_geckoview_upstream_artifacts(config, job)
+ return [
+ path
+ for upstream_artifact in upstream_artifacts
+ for path in upstream_artifact["paths"]
+ if not path.endswith(".md5") and not path.endswith(".sha1")
+ ]
diff --git a/taskcluster/taskgraph/util/taskcluster.py b/taskcluster/taskgraph/util/taskcluster.py
new file mode 100644
index 0000000000..ae6315d2f7
--- /dev/null
+++ b/taskcluster/taskgraph/util/taskcluster.py
@@ -0,0 +1,373 @@
+# -*- coding: utf-8 -*-
+
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import os
+import datetime
+import functools
+import requests
+import six
+import logging
+import taskcluster_urls as liburls
+from mozbuild.util import memoize
+from requests.packages.urllib3.util.retry import Retry
+from taskcluster import Hooks
+from taskgraph.task import Task
+from taskgraph.util import yaml
+
+logger = logging.getLogger(__name__)
+
+# this is set to true for `mach taskgraph action-callback --test`
+testing = False
+
+# Default rootUrl to use if none is given in the environment; this should point
+# to the production Taskcluster deployment used for CI.
+PRODUCTION_TASKCLUSTER_ROOT_URL = "https://firefox-ci-tc.services.mozilla.com"
+
+# the maximum number of parallel Taskcluster API calls to make
+CONCURRENCY = 50
+
+
+@memoize
+def get_root_url(use_proxy):
+ """Get the current TASKCLUSTER_ROOT_URL. When running in a task, this must
+ come from $TASKCLUSTER_ROOT_URL; when run on the command line, we apply a
+ defualt that points to the production deployment of Taskcluster. If use_proxy
+ is set, this attempts to get TASKCLUSTER_PROXY_URL instead, failing if it
+ is not set."""
+ if use_proxy:
+ try:
+ return six.ensure_text(os.environ["TASKCLUSTER_PROXY_URL"])
+ except KeyError:
+ if "TASK_ID" not in os.environ:
+ raise RuntimeError(
+ "taskcluster-proxy is not available when not executing in a task"
+ )
+ else:
+ raise RuntimeError("taskcluster-proxy is not enabled for this task")
+
+ if "TASKCLUSTER_ROOT_URL" not in os.environ:
+ if "TASK_ID" in os.environ:
+ raise RuntimeError(
+ "$TASKCLUSTER_ROOT_URL must be set when running in a task"
+ )
+ else:
+ logger.debug("Using default TASKCLUSTER_ROOT_URL (Firefox CI production)")
+ return PRODUCTION_TASKCLUSTER_ROOT_URL
+ logger.debug(
+ "Running in Taskcluster instance {}{}".format(
+ os.environ["TASKCLUSTER_ROOT_URL"],
+ " with taskcluster-proxy" if "TASKCLUSTER_PROXY_URL" in os.environ else "",
+ )
+ )
+ return six.ensure_text(os.environ["TASKCLUSTER_ROOT_URL"])
+
+
+def requests_retry_session(
+ retries,
+ backoff_factor=0.1,
+ status_forcelist=(500, 502, 504),
+ concurrency=CONCURRENCY,
+ session=None,
+):
+ session = session or requests.Session()
+ retry = Retry(
+ total=retries,
+ read=retries,
+ connect=retries,
+ backoff_factor=backoff_factor,
+ status_forcelist=status_forcelist,
+ )
+
+ # Default HTTPAdapter uses 10 connections. Mount custom adapter to increase
+ # that limit. Connections are established as needed, so using a large value
+ # should not negatively impact performance.
+ http_adapter = requests.adapters.HTTPAdapter(
+ pool_connections=concurrency,
+ pool_maxsize=concurrency,
+ max_retries=retry,
+ )
+ session.mount("http://", http_adapter)
+ session.mount("https://", http_adapter)
+
+ return session
+
+
+@memoize
+def get_session():
+ return requests_retry_session(retries=5)
+
+
+def _do_request(url, method=None, **kwargs):
+ if method is None:
+ method = "post" if kwargs else "get"
+
+ session = get_session()
+ if method == "get":
+ kwargs["stream"] = True
+ response = getattr(session, method)(url, **kwargs)
+
+ if response.status_code >= 400:
+ # Consume content before raise_for_status, so that the connection can be
+ # reused.
+ response.content
+ response.raise_for_status()
+ return response
+
+
+def _handle_artifact(path, response):
+ if path.endswith(".json"):
+ return response.json()
+ if path.endswith(".yml"):
+ return yaml.load_stream(response.text)
+ response.raw.read = functools.partial(response.raw.read, decode_content=True)
+ return response.raw
+
+
+def get_artifact_url(task_id, path, use_proxy=False):
+ artifact_tmpl = liburls.api(
+ get_root_url(False), "queue", "v1", "task/{}/artifacts/{}"
+ )
+ data = six.ensure_text(artifact_tmpl.format(task_id, path))
+ if use_proxy:
+ # Until Bug 1405889 is deployed, we can't download directly
+ # from the taskcluster-proxy. Work around by using the /bewit
+ # endpoint instead.
+ # The bewit URL is the body of a 303 redirect, which we don't
+ # want to follow (which fetches a potentially large resource).
+ response = _do_request(
+ os.environ["TASKCLUSTER_PROXY_URL"] + "/bewit",
+ data=data,
+ allow_redirects=False,
+ )
+ return six.ensure_text(response.text)
+ return data
+
+
+def get_artifact(task_id, path, use_proxy=False):
+ """
+ Returns the artifact with the given path for the given task id.
+
+ If the path ends with ".json" or ".yml", the content is deserialized as,
+ respectively, json or yaml, and the corresponding python data (usually
+ dict) is returned.
+ For other types of content, a file-like object is returned.
+ """
+ response = _do_request(get_artifact_url(task_id, path, use_proxy))
+ return _handle_artifact(path, response)
+
+
+def list_artifacts(task_id, use_proxy=False):
+ response = _do_request(get_artifact_url(task_id, "", use_proxy).rstrip("/"))
+ return response.json()["artifacts"]
+
+
+def get_artifact_prefix(task):
+ prefix = None
+ if isinstance(task, dict):
+ prefix = task.get("attributes", {}).get("artifact_prefix")
+ elif isinstance(task, Task):
+ prefix = task.attributes.get("artifact_prefix")
+ else:
+ raise Exception("Can't find artifact-prefix of non-task: {}".format(task))
+ return prefix or "public/build"
+
+
+def get_artifact_path(task, path):
+ return "{}/{}".format(get_artifact_prefix(task), path)
+
+
+def get_index_url(index_path, use_proxy=False, multiple=False):
+ index_tmpl = liburls.api(get_root_url(use_proxy), "index", "v1", "task{}/{}")
+ return index_tmpl.format("s" if multiple else "", index_path)
+
+
+def find_task_id(index_path):
+ try:
+ response = _do_request(get_index_url(index_path))
+ except requests.exceptions.HTTPError as e:
+ if e.response.status_code == 404:
+ raise KeyError("index path {} not found".format(index_path))
+ raise
+ return response.json()["taskId"]
+
+
+def get_artifact_from_index(index_path, artifact_path, use_proxy=False):
+ full_path = index_path + "/artifacts/" + artifact_path
+ response = _do_request(get_index_url(full_path, use_proxy))
+ return _handle_artifact(full_path, response)
+
+
+def list_tasks(index_path, use_proxy=False):
+ """
+ Returns a list of task_ids where each task_id is indexed under a path
+ in the index. Results are sorted by expiration date from oldest to newest.
+ """
+ results = []
+ data = {}
+ while True:
+ response = _do_request(
+ get_index_url(index_path, use_proxy, multiple=True), json=data
+ )
+ response = response.json()
+ results += response["tasks"]
+ if response.get("continuationToken"):
+ data = {"continuationToken": response.get("continuationToken")}
+ else:
+ break
+
+ # We can sort on expires because in the general case
+ # all of these tasks should be created with the same expires time so they end up in
+ # order from earliest to latest action. If more correctness is needed, consider
+ # fetching each task and sorting on the created date.
+ results.sort(key=lambda t: parse_time(t["expires"]))
+ return [t["taskId"] for t in results]
+
+
+def insert_index(index_path, task_id, data=None, use_proxy=False):
+ index_url = get_index_url(index_path, use_proxy=use_proxy)
+
+ # Find task expiry.
+ expires = get_task_definition(task_id, use_proxy=use_proxy)["expires"]
+
+ response = _do_request(
+ index_url,
+ method="put",
+ json={
+ "taskId": task_id,
+ "rank": 0,
+ "data": data or {},
+ "expires": expires,
+ },
+ )
+ return response
+
+
+def parse_time(timestamp):
+ """Turn a "JSON timestamp" as used in TC APIs into a datetime"""
+ return datetime.datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S.%fZ")
+
+
+def get_task_url(task_id, use_proxy=False):
+ task_tmpl = liburls.api(get_root_url(use_proxy), "queue", "v1", "task/{}")
+ return task_tmpl.format(task_id)
+
+
+def get_task_definition(task_id, use_proxy=False):
+ response = _do_request(get_task_url(task_id, use_proxy))
+ return response.json()
+
+
+def cancel_task(task_id, use_proxy=False):
+ """Cancels a task given a task_id. In testing mode, just logs that it would
+ have cancelled."""
+ if testing:
+ logger.info("Would have cancelled {}.".format(task_id))
+ else:
+ _do_request(get_task_url(task_id, use_proxy) + "/cancel", json={})
+
+
+def status_task(task_id, use_proxy=False):
+ """Gets the status of a task given a task_id. In testing mode, just logs that it would
+ have retrieved status."""
+ if testing:
+ logger.info("Would have gotten status for {}.".format(task_id))
+ else:
+ resp = _do_request(get_task_url(task_id, use_proxy) + "/status")
+ status = resp.json().get("status", {}).get("state") or "unknown"
+ return status
+
+
+def rerun_task(task_id):
+ """Reruns a task given a task_id. In testing mode, just logs that it would
+ have reran."""
+ if testing:
+ logger.info("Would have rerun {}.".format(task_id))
+ else:
+ _do_request(get_task_url(task_id, use_proxy=True) + "/rerun", json={})
+
+
+def trigger_hook(hook_group_id, hook_id, hook_payload):
+ hooks = Hooks({"rootUrl": get_root_url(True)})
+ response = hooks.triggerHook(hook_group_id, hook_id, hook_payload)
+
+ logger.info(
+ "Task seen here: {}/tasks/{}".format(
+ get_root_url(os.environ.get("TASKCLUSTER_PROXY_URL")),
+ response["status"]["taskId"],
+ )
+ )
+
+
+def get_current_scopes():
+ """Get the current scopes. This only makes sense in a task with the Taskcluster
+ proxy enabled, where it returns the actual scopes accorded to the task."""
+ auth_url = liburls.api(get_root_url(True), "auth", "v1", "scopes/current")
+ resp = _do_request(auth_url)
+ return resp.json().get("scopes", [])
+
+
+def get_purge_cache_url(provisioner_id, worker_type, use_proxy=False):
+ url_tmpl = liburls.api(
+ get_root_url(use_proxy), "purge-cache", "v1", "purge-cache/{}/{}"
+ )
+ return url_tmpl.format(provisioner_id, worker_type)
+
+
+def purge_cache(provisioner_id, worker_type, cache_name, use_proxy=False):
+ """Requests a cache purge from the purge-caches service."""
+ if testing:
+ logger.info(
+ "Would have purged {}/{}/{}.".format(
+ provisioner_id, worker_type, cache_name
+ )
+ )
+ else:
+ logger.info("Purging {}/{}/{}.".format(provisioner_id, worker_type, cache_name))
+ purge_cache_url = get_purge_cache_url(provisioner_id, worker_type, use_proxy)
+ _do_request(purge_cache_url, json={"cacheName": cache_name})
+
+
+def send_email(address, subject, content, link, use_proxy=False):
+ """Sends an email using the notify service"""
+ logger.info("Sending email to {}.".format(address))
+ url = liburls.api(get_root_url(use_proxy), "notify", "v1", "email")
+ _do_request(
+ url,
+ json={
+ "address": address,
+ "subject": subject,
+ "content": content,
+ "link": link,
+ },
+ )
+
+
+def list_task_group_tasks(task_group_id):
+ """Generate the tasks in a task group"""
+ params = {}
+ while True:
+ url = liburls.api(
+ get_root_url(False),
+ "queue",
+ "v1",
+ "task-group/{}/list".format(task_group_id),
+ )
+ resp = _do_request(url, method="get", params=params).json()
+ for task in resp["tasks"]:
+ yield task
+ if resp.get("continuationToken"):
+ params = {"continuationToken": resp.get("continuationToken")}
+ else:
+ break
+
+
+def list_task_group_incomplete_task_ids(task_group_id):
+ states = ("running", "pending", "unscheduled")
+ for task in [t["status"] for t in list_task_group_tasks(task_group_id)]:
+ if task["state"] in states:
+ yield task["taskId"]
diff --git a/taskcluster/taskgraph/util/taskgraph.py b/taskcluster/taskgraph/util/taskgraph.py
new file mode 100644
index 0000000000..735bfbb15a
--- /dev/null
+++ b/taskcluster/taskgraph/util/taskgraph.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+
+# 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/.
+
+"""
+Tools for interacting with existing taskgraphs.
+"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import six
+
+from taskgraph.util.taskcluster import (
+ find_task_id,
+ get_artifact,
+)
+
+
+def find_decision_task(parameters, graph_config):
+ """Given the parameters for this action, find the taskId of the decision
+ task"""
+ head_rev_param = "{}head_rev".format(graph_config["project-repo-param-prefix"])
+ return find_task_id(
+ "{}.v2.{}.revision.{}.taskgraph.decision".format(
+ graph_config["trust-domain"],
+ parameters["project"],
+ parameters[head_rev_param],
+ )
+ )
+
+
+def find_existing_tasks(previous_graph_ids):
+ existing_tasks = {}
+ for previous_graph_id in previous_graph_ids:
+ label_to_taskid = get_artifact(previous_graph_id, "public/label-to-taskid.json")
+ existing_tasks.update(label_to_taskid)
+ return existing_tasks
+
+
+def find_existing_tasks_from_previous_kinds(
+ full_task_graph, previous_graph_ids, rebuild_kinds
+):
+ """Given a list of previous decision/action taskIds and kinds to ignore
+ from the previous graphs, return a dictionary of labels-to-taskids to use
+ as ``existing_tasks`` in the optimization step."""
+ existing_tasks = find_existing_tasks(previous_graph_ids)
+ kind_labels = {
+ t.label
+ for t in six.itervalues(full_task_graph.tasks)
+ if t.attributes["kind"] not in rebuild_kinds
+ }
+ return {
+ label: taskid
+ for (label, taskid) in existing_tasks.items()
+ if label in kind_labels
+ }
diff --git a/taskcluster/taskgraph/util/templates.py b/taskcluster/taskgraph/util/templates.py
new file mode 100644
index 0000000000..d0cfb251c3
--- /dev/null
+++ b/taskcluster/taskgraph/util/templates.py
@@ -0,0 +1,60 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import copy
+
+
+def merge_to(source, dest):
+ """
+ Merge dict and arrays (override scalar values)
+
+ Keys from source override keys from dest, and elements from lists in source
+ are appended to lists in dest.
+
+ :param dict source: to copy from
+ :param dict dest: to copy to (modified in place)
+ """
+
+ for key, value in source.items():
+ if (
+ isinstance(value, dict)
+ and len(value) == 1
+ and list(value)[0].startswith("by-")
+ ):
+ # Do not merge by-* values as this is likely to confuse someone
+ dest[key] = value
+ continue
+
+ # Override mismatching or empty types
+ if type(value) != type(dest.get(key)): # noqa
+ dest[key] = value
+ continue
+
+ # Merge dict
+ if isinstance(value, dict):
+ merge_to(value, dest[key])
+ continue
+
+ if isinstance(value, list):
+ dest[key] = dest[key] + value
+ continue
+
+ dest[key] = value
+
+ return dest
+
+
+def merge(*objects):
+ """
+ Merge the given objects, using the semantics described for merge_to, with
+ objects later in the list taking precedence. From an inheritance
+ perspective, "parents" should be listed before "children".
+
+ Returns the result without modifying any arguments.
+ """
+ if len(objects) == 1:
+ return copy.deepcopy(objects[0])
+ return merge_to(objects[-1], merge(*objects[:-1]))
diff --git a/taskcluster/taskgraph/util/time.py b/taskcluster/taskgraph/util/time.py
new file mode 100644
index 0000000000..184d92f0c0
--- /dev/null
+++ b/taskcluster/taskgraph/util/time.py
@@ -0,0 +1,118 @@
+# 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/.
+
+# Python port of the ms.js node module this is not a direct port some things are
+# more complicated or less precise and we lean on time delta here.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import re
+import datetime
+
+PATTERN = re.compile("((?:\d+)?\.?\d+) *([a-z]+)")
+
+
+def seconds(value):
+ return datetime.timedelta(seconds=int(value))
+
+
+def minutes(value):
+ return datetime.timedelta(minutes=int(value))
+
+
+def hours(value):
+ return datetime.timedelta(hours=int(value))
+
+
+def days(value):
+ return datetime.timedelta(days=int(value))
+
+
+def months(value):
+ # See warning in years(), below
+ return datetime.timedelta(days=int(value) * 30)
+
+
+def years(value):
+ # Warning here "years" are vague don't use this for really sensitive date
+ # computation the idea is to give you a absolute amount of time in the
+ # future which is not the same thing as "precisely on this date next year"
+ return datetime.timedelta(days=int(value) * 365)
+
+
+ALIASES = {}
+ALIASES["seconds"] = ALIASES["second"] = ALIASES["s"] = seconds
+ALIASES["minutes"] = ALIASES["minute"] = ALIASES["min"] = minutes
+ALIASES["hours"] = ALIASES["hour"] = ALIASES["h"] = hours
+ALIASES["days"] = ALIASES["day"] = ALIASES["d"] = days
+ALIASES["months"] = ALIASES["month"] = ALIASES["mo"] = months
+ALIASES["years"] = ALIASES["year"] = ALIASES["y"] = years
+
+
+class InvalidString(Exception):
+ pass
+
+
+class UnknownTimeMeasurement(Exception):
+ pass
+
+
+def value_of(input_str):
+ """
+ Convert a string to a json date in the future
+ :param str input_str: (ex: 1d, 2d, 6years, 2 seconds)
+ :returns: Unit given in seconds
+ """
+
+ matches = PATTERN.search(input_str)
+
+ if matches is None or len(matches.groups()) < 2:
+ raise InvalidString("'{}' is invalid string".format(input_str))
+
+ value, unit = matches.groups()
+
+ if unit not in ALIASES:
+ raise UnknownTimeMeasurement(
+ "{} is not a valid time measure use one of {}".format(
+ unit, sorted(ALIASES.keys())
+ )
+ )
+
+ return ALIASES[unit](value)
+
+
+def json_time_from_now(input_str, now=None, datetime_format=False):
+ """
+ :param str input_str: Input string (see value of)
+ :param datetime now: Optionally set the definition of `now`
+ :param boolean datetime_format: Set `True` to get a `datetime` output
+ :returns: JSON string representation of time in future.
+ """
+
+ if now is None:
+ now = datetime.datetime.utcnow()
+
+ time = now + value_of(input_str)
+
+ if datetime_format is True:
+ return time
+ else:
+ # Sorta a big hack but the json schema validator for date does not like the
+ # ISO dates until 'Z' (for timezone) is added...
+ # the [:23] ensures only whole seconds or milliseconds are included,
+ # not microseconds (see bug 1381801)
+ return time.isoformat()[:23] + "Z"
+
+
+def current_json_time(datetime_format=False):
+ """
+ :param boolean datetime_format: Set `True` to get a `datetime` output
+ :returns: JSON string representation of the current time.
+ """
+ if datetime_format is True:
+ return datetime.datetime.utcnow()
+ else:
+ # the [:23] ensures only whole seconds or milliseconds are included,
+ # not microseconds (see bug 1381801)
+ return datetime.datetime.utcnow().isoformat()[:23] + "Z"
diff --git a/taskcluster/taskgraph/util/treeherder.py b/taskcluster/taskgraph/util/treeherder.py
new file mode 100644
index 0000000000..9e7429ef64
--- /dev/null
+++ b/taskcluster/taskgraph/util/treeherder.py
@@ -0,0 +1,67 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+import re
+
+_JOINED_SYMBOL_RE = re.compile(r"([^(]*)\(([^)]*)\)$")
+
+
+def split_symbol(treeherder_symbol):
+ """Split a symbol expressed as grp(sym) into its two parts. If no group is
+ given, the returned group is '?'"""
+ groupSymbol = "?"
+ symbol = treeherder_symbol
+ if "(" in symbol:
+ match = _JOINED_SYMBOL_RE.match(symbol)
+ if match:
+ groupSymbol, symbol = match.groups()
+ else:
+ raise Exception("`{}` is not a valid treeherder symbol.".format(symbol))
+ return groupSymbol, symbol
+
+
+def join_symbol(group, symbol):
+ """Perform the reverse of split_symbol, combining the given group and
+ symbol. If the group is '?', then it is omitted."""
+ if group == "?":
+ return symbol
+ return "{}({})".format(group, symbol)
+
+
+def add_suffix(treeherder_symbol, suffix):
+ """Add a suffix to a treeherder symbol that may contain a group."""
+ group, symbol = split_symbol(treeherder_symbol)
+ symbol += str(suffix)
+ return join_symbol(group, symbol)
+
+
+def replace_group(treeherder_symbol, new_group):
+ """Add a suffix to a treeherder symbol that may contain a group."""
+ _, symbol = split_symbol(treeherder_symbol)
+ return join_symbol(new_group, symbol)
+
+
+def inherit_treeherder_from_dep(job, dep_job):
+ """Inherit treeherder defaults from dep_job"""
+ treeherder = job.get("treeherder", {})
+
+ dep_th_platform = (
+ dep_job.task.get("extra", {})
+ .get("treeherder", {})
+ .get("machine", {})
+ .get("platform", "")
+ )
+ dep_th_collection = list(
+ dep_job.task.get("extra", {}).get("treeherder", {}).get("collection", {}).keys()
+ )[0]
+ treeherder.setdefault(
+ "platform", "{}/{}".format(dep_th_platform, dep_th_collection)
+ )
+ treeherder.setdefault(
+ "tier", dep_job.task.get("extra", {}).get("treeherder", {}).get("tier", 1)
+ )
+ # Does not set symbol
+ treeherder.setdefault("kind", "build")
+ return treeherder
diff --git a/taskcluster/taskgraph/util/verify.py b/taskcluster/taskgraph/util/verify.py
new file mode 100644
index 0000000000..a1d24718c9
--- /dev/null
+++ b/taskcluster/taskgraph/util/verify.py
@@ -0,0 +1,454 @@
+# -*- coding: utf-8 -*-
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import logging
+import re
+import os
+
+import attr
+import six
+
+from .. import GECKO
+from .treeherder import join_symbol
+from taskgraph.util.attributes import match_run_on_projects, RELEASE_PROJECTS
+
+from taskgraph.util.attributes import ALL_PROJECTS, RUN_ON_PROJECT_ALIASES
+
+logger = logging.getLogger(__name__)
+doc_base_path = os.path.join(GECKO, "taskcluster", "docs")
+
+
+@attr.s(frozen=True)
+class Verification(object):
+ verify = attr.ib()
+ run_on_projects = attr.ib()
+
+
+@attr.s(frozen=True)
+class VerificationSequence(object):
+ """
+ Container for a sequence of verifications over a TaskGraph. Each
+ verification is represented as a callable taking (task, taskgraph,
+ scratch_pad), called for each task in the taskgraph, and one more
+ time with no task but with the taskgraph and the same scratch_pad
+ that was passed for each task.
+ """
+
+ _verifications = attr.ib(factory=dict)
+
+ def __call__(self, graph_name, graph, graph_config, parameters):
+ for verification in self._verifications.get(graph_name, []):
+ if not match_run_on_projects(
+ parameters["project"], verification.run_on_projects
+ ):
+ continue
+ scratch_pad = {}
+ graph.for_each_task(
+ verification.verify,
+ scratch_pad=scratch_pad,
+ graph_config=graph_config,
+ parameters=parameters,
+ )
+ verification.verify(
+ None,
+ graph,
+ scratch_pad=scratch_pad,
+ graph_config=graph_config,
+ parameters=parameters,
+ )
+ return graph_name, graph
+
+ def add(self, graph_name, run_on_projects={"all"}):
+ def wrap(func):
+ self._verifications.setdefault(graph_name, []).append(
+ Verification(func, run_on_projects)
+ )
+ return func
+
+ return wrap
+
+
+verifications = VerificationSequence()
+
+
+@attr.s(frozen=True)
+class DocPaths(object):
+ _paths = attr.ib(factory=list)
+
+ def get_files(self, filename):
+ rv = []
+ for p in self._paths:
+ doc_path = os.path.join(p, filename)
+ if os.path.exists(doc_path):
+ rv.append(doc_path)
+ return rv
+
+ def add(self, path):
+ """
+ Projects that make use of Firefox's taskgraph can extend it with
+ their own task kinds by registering additional paths for documentation.
+ documentation_paths.add() needs to be called by the project's Taskgraph
+ registration function. See taskgraph.config.
+ """
+ self._paths.append(path)
+
+
+documentation_paths = DocPaths()
+documentation_paths.add(doc_base_path)
+
+
+def verify_docs(filename, identifiers, appearing_as):
+ """
+ Look for identifiers of the type appearing_as in the files
+ returned by documentation_paths.get_files(). Firefox will have
+ a single file in a list, but projects such as Thunderbird can have
+ documentation in another location and may return multiple files.
+ """
+ # We ignore identifiers starting with '_' for the sake of tests.
+ # Strings starting with "_" are ignored for doc verification
+ # hence they can be used for faking test values
+ doc_files = documentation_paths.get_files(filename)
+ doctext = "".join([open(d).read() for d in doc_files])
+
+ if appearing_as == "inline-literal":
+ expression_list = [
+ "``" + identifier + "``"
+ for identifier in identifiers
+ if not identifier.startswith("_")
+ ]
+ elif appearing_as == "heading":
+ expression_list = [
+ "\n" + identifier + "\n(?:(?:(?:-+\n)+)|(?:(?:.+\n)+))"
+ for identifier in identifiers
+ if not identifier.startswith("_")
+ ]
+ else:
+ raise Exception("appearing_as = `{}` not defined".format(appearing_as))
+
+ for expression, identifier in zip(expression_list, identifiers):
+ match_group = re.search(expression, doctext)
+ if not match_group:
+ raise Exception(
+ "{}: `{}` missing from doc file: `{}`".format(
+ appearing_as, identifier, filename
+ )
+ )
+
+
+@verifications.add("full_task_graph")
+def verify_task_graph_symbol(task, taskgraph, scratch_pad, graph_config, parameters):
+ """
+ This function verifies that tuple
+ (collection.keys(), machine.platform, groupSymbol, symbol) is unique
+ for a target task graph.
+ """
+ if task is None:
+ return
+ task_dict = task.task
+ if "extra" in task_dict:
+ extra = task_dict["extra"]
+ if "treeherder" in extra:
+ treeherder = extra["treeherder"]
+
+ collection_keys = tuple(sorted(treeherder.get("collection", {}).keys()))
+ if len(collection_keys) != 1:
+ raise Exception(
+ "Task {} can't be in multiple treeherder collections "
+ "(the part of the platform after `/`): {}".format(
+ task.label, collection_keys
+ )
+ )
+ platform = treeherder.get("machine", {}).get("platform")
+ group_symbol = treeherder.get("groupSymbol")
+ symbol = treeherder.get("symbol")
+
+ key = (platform, collection_keys[0], group_symbol, symbol)
+ if key in scratch_pad:
+ raise Exception(
+ "Duplicate treeherder platform and symbol in tasks "
+ "`{}`and `{}`: {} {}".format(
+ task.label,
+ scratch_pad[key],
+ "{}/{}".format(platform, collection_keys[0]),
+ join_symbol(group_symbol, symbol),
+ )
+ )
+ else:
+ scratch_pad[key] = task.label
+
+
+@verifications.add("full_task_graph")
+def verify_trust_domain_v2_routes(
+ task, taskgraph, scratch_pad, graph_config, parameters
+):
+ """
+ This function ensures that any two tasks have distinct ``index.{trust-domain}.v2`` routes.
+ """
+ if task is None:
+ return
+ route_prefix = "index.{}.v2".format(graph_config["trust-domain"])
+ task_dict = task.task
+ routes = task_dict.get("routes", [])
+
+ for route in routes:
+ if route.startswith(route_prefix):
+ if route in scratch_pad:
+ raise Exception(
+ "conflict between {}:{} for route: {}".format(
+ task.label, scratch_pad[route], route
+ )
+ )
+ else:
+ scratch_pad[route] = task.label
+
+
+@verifications.add("full_task_graph")
+def verify_routes_notification_filters(
+ task, taskgraph, scratch_pad, graph_config, parameters
+):
+ """
+ This function ensures that only understood filters for notifications are
+ specified.
+
+ See: https://firefox-ci-tc.services.mozilla.com/docs/manual/using/task-notifications
+ """
+ if task is None:
+ return
+ route_prefix = "notify."
+ valid_filters = ("on-any", "on-completed", "on-failed", "on-exception")
+ task_dict = task.task
+ routes = task_dict.get("routes", [])
+
+ for route in routes:
+ if route.startswith(route_prefix):
+ # Get the filter of the route
+ route_filter = route.split(".")[-1]
+ if route_filter not in valid_filters:
+ raise Exception(
+ "{} has invalid notification filter ({})".format(
+ task.label, route_filter
+ )
+ )
+
+
+@verifications.add("full_task_graph")
+def verify_dependency_tiers(task, taskgraph, scratch_pad, graph_config, parameters):
+ tiers = scratch_pad
+ if task is not None:
+ tiers[task.label] = (
+ task.task.get("extra", {}).get("treeherder", {}).get("tier", six.MAXSIZE)
+ )
+ else:
+
+ def printable_tier(tier):
+ if tier == six.MAXSIZE:
+ return "unknown"
+ return tier
+
+ for task in six.itervalues(taskgraph.tasks):
+ tier = tiers[task.label]
+ for d in six.itervalues(task.dependencies):
+ if taskgraph[d].task.get("workerType") == "always-optimized":
+ continue
+ if "dummy" in taskgraph[d].kind:
+ continue
+ if tier < tiers[d]:
+ raise Exception(
+ "{} (tier {}) cannot depend on {} (tier {})".format(
+ task.label,
+ printable_tier(tier),
+ d,
+ printable_tier(tiers[d]),
+ )
+ )
+
+
+@verifications.add("full_task_graph")
+def verify_required_signoffs(task, taskgraph, scratch_pad, graph_config, parameters):
+ """
+ Task with required signoffs can't be dependencies of tasks with less
+ required signoffs.
+ """
+ all_required_signoffs = scratch_pad
+ if task is not None:
+ all_required_signoffs[task.label] = set(
+ task.attributes.get("required_signoffs", [])
+ )
+ else:
+
+ def printable_signoff(signoffs):
+ if len(signoffs) == 1:
+ return "required signoff {}".format(*signoffs)
+ elif signoffs:
+ return "required signoffs {}".format(", ".join(signoffs))
+ else:
+ return "no required signoffs"
+
+ for task in six.itervalues(taskgraph.tasks):
+ required_signoffs = all_required_signoffs[task.label]
+ for d in six.itervalues(task.dependencies):
+ if required_signoffs < all_required_signoffs[d]:
+ raise Exception(
+ "{} ({}) cannot depend on {} ({})".format(
+ task.label,
+ printable_signoff(required_signoffs),
+ d,
+ printable_signoff(all_required_signoffs[d]),
+ )
+ )
+
+
+@verifications.add("full_task_graph")
+def verify_toolchain_alias(task, taskgraph, scratch_pad, graph_config, parameters):
+ """
+ This function verifies that toolchain aliases are not reused.
+ """
+ if task is None:
+ return
+ attributes = task.attributes
+ if "toolchain-alias" in attributes:
+ key = attributes["toolchain-alias"]
+ if key in scratch_pad:
+ raise Exception(
+ "Duplicate toolchain-alias in tasks "
+ "`{}`and `{}`: {}".format(
+ task.label,
+ scratch_pad[key],
+ key,
+ )
+ )
+ else:
+ scratch_pad[key] = task.label
+
+
+@verifications.add("optimized_task_graph")
+def verify_always_optimized(task, taskgraph, scratch_pad, graph_config, parameters):
+ """
+ This function ensures that always-optimized tasks have been optimized.
+ """
+ if task is None:
+ return
+ if task.task.get("workerType") == "always-optimized":
+ raise Exception("Could not optimize the task {!r}".format(task.label))
+
+
+@verifications.add("full_task_graph", run_on_projects=RELEASE_PROJECTS)
+def verify_shippable_no_sccache(task, taskgraph, scratch_pad, graph_config, parameters):
+ if task and task.attributes.get("shippable"):
+ if task.task.get("payload", {}).get("env", {}).get("USE_SCCACHE"):
+ raise Exception("Shippable job {} cannot use sccache".format(task.label))
+
+
+@verifications.add("full_task_graph")
+def verify_test_packaging(task, taskgraph, scratch_pad, graph_config, parameters):
+ if task is None:
+ # In certain cases there are valid reasons for tests to be missing,
+ # don't error out when that happens.
+ missing_tests_allowed = any(
+ (
+ # user specified `--target-kind`
+ parameters.get("target-kind") is not None,
+ # manifest scheduling is enabled
+ parameters["test_manifest_loader"] != "default",
+ )
+ )
+
+ exceptions = []
+ for task in six.itervalues(taskgraph.tasks):
+ if task.kind == "build" and not task.attributes.get(
+ "skip-verify-test-packaging"
+ ):
+ build_env = task.task.get("payload", {}).get("env", {})
+ package_tests = build_env.get("MOZ_AUTOMATION_PACKAGE_TESTS")
+ shippable = task.attributes.get("shippable", False)
+ build_has_tests = scratch_pad.get(task.label)
+
+ if package_tests != "1":
+ # Shippable builds should always package tests.
+ if shippable:
+ exceptions.append(
+ "Build job {} is shippable and does not specify "
+ "MOZ_AUTOMATION_PACKAGE_TESTS=1 in the "
+ "environment.".format(task.label)
+ )
+
+ # Build tasks in the scratch pad have tests dependent on
+ # them, so we need to package tests during build.
+ if build_has_tests:
+ exceptions.append(
+ "Build job {} has tests dependent on it and does not specify "
+ "MOZ_AUTOMATION_PACKAGE_TESTS=1 in the environment".format(
+ task.label
+ )
+ )
+ else:
+ # Build tasks that aren't in the scratch pad have no
+ # dependent tests, so we shouldn't package tests.
+ # With the caveat that we expect shippable jobs to always
+ # produce tests.
+ if not build_has_tests and not shippable:
+ # If we have not generated all task kinds, we can't verify that
+ # there are no dependent tests.
+ if not missing_tests_allowed:
+ exceptions.append(
+ "Build job {} has no tests, but specifies "
+ "MOZ_AUTOMATION_PACKAGE_TESTS={} in the environment. "
+ "Unset MOZ_AUTOMATION_PACKAGE_TESTS in the task definition "
+ "to fix.".format(task.label, package_tests)
+ )
+ if exceptions:
+ raise Exception("\n".join(exceptions))
+ return
+ if task.kind == "test":
+ build_task = taskgraph[task.dependencies["build"]]
+ scratch_pad[build_task.label] = 1
+
+
+@verifications.add("full_task_graph")
+def verify_run_known_projects(task, taskgraph, scratch_pad, graph_config, parameters):
+ """Validates the inputs in run-on-projects.
+
+ We should never let 'try' (or 'try-comm-central') be in run-on-projects even though it
+ is valid because it is not considered for try pushes. While here we also validate for
+ other unknown projects or typos.
+ """
+ if task and task.attributes.get("run_on_projects"):
+ projects = set(task.attributes["run_on_projects"])
+ if {"try", "try-comm-central"} & set(projects):
+ raise Exception(
+ "In task {}: using try in run-on-projects is invalid; use try "
+ "selectors to select this task on try".format(task.label)
+ )
+ # try isn't valid, but by the time we get here its not an available project anyway.
+ valid_projects = ALL_PROJECTS | set(RUN_ON_PROJECT_ALIASES.keys())
+ invalid_projects = projects - valid_projects
+ if invalid_projects:
+ raise Exception(
+ "Task '{}' has an invalid run-on-projects value: "
+ "{}".format(task.label, invalid_projects)
+ )
+
+
+@verifications.add("full_task_graph")
+def verify_local_toolchains(task, taskgraph, scratch_pad, graph_config, parameters):
+ """
+ Toolchains that are used for local development need to be built on a
+ level-3 branch to installable via `mach bootstrap`. We ensure here that all
+ such tasks run on at least trunk projects, even if they aren't pulled in as
+ a dependency of other tasks in the graph.
+
+ There is code in `mach artifact toolchain` that verifies that anything
+ installed via `mach bootstrap` has the attribute set.
+ """
+ if task and task.attributes.get("local-toolchain"):
+ run_on_projects = task.attributes.get("run_on_projects", [])
+ if not any(alias in run_on_projects for alias in ["all", "trunk"]):
+ raise Exception(
+ "Toolchain {} used for local development is not built on trunk. {}".format(
+ task.label, run_on_projects
+ )
+ )
diff --git a/taskcluster/taskgraph/util/workertypes.py b/taskcluster/taskgraph/util/workertypes.py
new file mode 100644
index 0000000000..6a9f571a69
--- /dev/null
+++ b/taskcluster/taskgraph/util/workertypes.py
@@ -0,0 +1,95 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+from mozbuild.util import memoize
+
+from .keyed_by import evaluate_keyed_by
+from .attributes import keymatch
+
+WORKER_TYPES = {
+ "gce/gecko-1-b-linux": ("docker-worker", "linux"),
+ "gce/gecko-2-b-linux": ("docker-worker", "linux"),
+ "gce/gecko-3-b-linux": ("docker-worker", "linux"),
+ "invalid/invalid": ("invalid", None),
+ "invalid/always-optimized": ("always-optimized", None),
+ "scriptworker-prov-v1/signing-linux-v1": ("scriptworker-signing", None),
+ "scriptworker-k8s/gecko-3-shipit": ("shipit", None),
+ "scriptworker-k8s/gecko-1-shipit": ("shipit", None),
+}
+
+
+@memoize
+def _get(graph_config, alias, level, release_level):
+ """Get the configuration for this worker_type alias: {provisioner,
+ worker-type, implementation, os}"""
+ level = str(level)
+
+ # handle the legacy (non-alias) format
+ if "/" in alias:
+ alias = alias.format(level=level)
+ provisioner, worker_type = alias.split("/", 1)
+ try:
+ implementation, os = WORKER_TYPES[alias]
+ return {
+ "provisioner": provisioner,
+ "worker-type": worker_type,
+ "implementation": implementation,
+ "os": os,
+ }
+ except KeyError:
+ return {
+ "provisioner": provisioner,
+ "worker-type": worker_type,
+ }
+
+ matches = keymatch(graph_config["workers"]["aliases"], alias)
+ if len(matches) > 1:
+ raise KeyError("Multiple matches for worker-type alias " + alias)
+ elif not matches:
+ raise KeyError("No matches for worker-type alias " + alias)
+ worker_config = matches[0].copy()
+
+ worker_config["provisioner"] = evaluate_keyed_by(
+ worker_config["provisioner"],
+ "worker-type alias {} field provisioner".format(alias),
+ {"level": level},
+ ).format(
+ **{
+ "trust-domain": graph_config["trust-domain"],
+ "level": level,
+ "alias": alias,
+ }
+ )
+ worker_config["worker-type"] = evaluate_keyed_by(
+ worker_config["worker-type"],
+ "worker-type alias {} field worker-type".format(alias),
+ {"level": level, "release-level": release_level},
+ ).format(
+ **{
+ "trust-domain": graph_config["trust-domain"],
+ "level": level,
+ "alias": alias,
+ }
+ )
+
+ return worker_config
+
+
+def worker_type_implementation(graph_config, worker_type):
+ """Get the worker implementation and OS for the given workerType, where the
+ OS represents the host system, not the target OS, in the case of
+ cross-compiles."""
+ worker_config = _get(graph_config, worker_type, "1", "staging")
+ return worker_config["implementation"], worker_config.get("os")
+
+
+def get_worker_type(graph_config, worker_type, level, release_level):
+ """
+ Get the worker type provisioner and worker-type, optionally evaluating
+ aliases from the graph config.
+ """
+ worker_config = _get(graph_config, worker_type, level, release_level)
+ return worker_config["provisioner"], worker_config["worker-type"]
diff --git a/taskcluster/taskgraph/util/yaml.py b/taskcluster/taskgraph/util/yaml.py
new file mode 100644
index 0000000000..9f1015c541
--- /dev/null
+++ b/taskcluster/taskgraph/util/yaml.py
@@ -0,0 +1,37 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import os
+
+from yaml.loader import SafeLoader
+
+
+class UnicodeLoader(SafeLoader):
+ def construct_yaml_str(self, node):
+ return self.construct_scalar(node)
+
+
+UnicodeLoader.add_constructor("tag:yaml.org,2002:str", UnicodeLoader.construct_yaml_str)
+
+
+def load_stream(stream):
+ """
+ Parse the first YAML document in a stream
+ and produce the corresponding Python object.
+ """
+ loader = UnicodeLoader(stream)
+ try:
+ return loader.get_single_data()
+ finally:
+ loader.dispose()
+
+
+def load_yaml(*parts):
+ """Convenience function to load a YAML file in the given path. This is
+ useful for loading kind configuration files from the kind path."""
+ filename = os.path.join(*parts)
+ with open(filename, "rb") as f:
+ return load_stream(f)