tor-browser

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

registry.py (12980B)


      1 # This Source Code Form is subject to the terms of the Mozilla Public
      2 # License, v. 2.0. If a copy of the MPL was not distributed with this
      3 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
      4 
      5 from collections import namedtuple
      6 from types import FunctionType
      7 
      8 from mozbuild.util import memoize
      9 from taskgraph import create
     10 from taskgraph.config import load_graph_config
     11 from taskgraph.parameters import Parameters
     12 from taskgraph.util import json, taskcluster, yaml
     13 from taskgraph.util.python_path import import_sibling_modules
     14 
     15 from gecko_taskgraph.util import hash
     16 
     17 actions = []
     18 callbacks = {}
     19 
     20 Action = namedtuple("Action", ["order", "cb_name", "permission", "action_builder"])
     21 
     22 
     23 def is_json(data):
     24    """Return ``True``, if ``data`` is a JSON serializable data structure."""
     25    try:
     26        json.dumps(data)
     27    except ValueError:
     28        return False
     29    return True
     30 
     31 
     32 @memoize
     33 def read_taskcluster_yml(filename):
     34    """Load and parse .taskcluster.yml, memoized to save some time"""
     35    return yaml.load_yaml(filename)
     36 
     37 
     38 @memoize
     39 def hash_taskcluster_yml(filename):
     40    """
     41    Generate a hash of the given .taskcluster.yml.  This is the first 10 digits
     42    of the sha256 of the file's content, and is used by administrative scripts
     43    to create a hook based on this content.
     44    """
     45    return hash.hash_path(filename)[:10]
     46 
     47 
     48 def register_callback_action(
     49    name,
     50    title,
     51    symbol,
     52    description,
     53    order=10000,
     54    context=[],
     55    available=lambda parameters: True,
     56    schema=None,
     57    permission="generic",
     58    cb_name=None,
     59 ):
     60    """
     61    Register an action callback that can be triggered from supporting
     62    user interfaces, such as Treeherder.
     63 
     64    This function is to be used as a decorator for a callback that takes
     65    parameters as follows:
     66 
     67    ``parameters``:
     68        Decision task parameters, see ``taskgraph.parameters.Parameters``.
     69    ``input``:
     70        Input matching specified JSON schema, ``None`` if no ``schema``
     71        parameter is given to ``register_callback_action``.
     72    ``task_group_id``:
     73        The id of the task-group this was triggered for.
     74    ``task_id`` and `task``:
     75        task identifier and task definition for task the action was triggered
     76        for, ``None`` if no ``context`` parameters was given to
     77        ``register_callback_action``.
     78 
     79    Parameters
     80    ----------
     81    name : str
     82        An identifier for this action, used by UIs to find the action.
     83    title : str
     84        A human readable title for the action to be used as label on a button
     85        or text on a link for triggering the action.
     86    symbol : str
     87        Treeherder symbol for the action callback, this is the symbol that the
     88        task calling your callback will be displayed as. This is usually 1-3
     89        letters abbreviating the action title.
     90    description : str
     91        A human readable description of the action in **markdown**.
     92        This will be display as tooltip and in dialog window when the action
     93        is triggered. This is a good place to describe how to use the action.
     94    order : int
     95        Order of the action in menus, this is relative to the ``order`` of
     96        other actions declared.
     97    context : list of dict
     98        List of tag-sets specifying which tasks the action is can take as input.
     99        If no tag-sets is specified as input the action is related to the
    100        entire task-group, and won't be triggered with a given task.
    101 
    102        Otherwise, if ``context = [{'k': 'b', 'p': 'l'}, {'k': 't'}]`` will only
    103        be displayed in the context menu for tasks that has
    104        ``task.tags.k == 'b' && task.tags.p = 'l'`` or ``task.tags.k = 't'``.
    105        Esentially, this allows filtering on ``task.tags``.
    106 
    107        If this is a function, it is given the decision parameters and must return
    108        a value of the form described above.
    109    available : function
    110        An optional function that given decision parameters decides if the
    111        action is available. Defaults to a function that always returns ``True``.
    112    schema : dict
    113        JSON schema specifying input accepted by the action.
    114        This is optional and can be left ``null`` if no input is taken.
    115    permission : string
    116        This defaults to ``generic`` and needs to be set for actions that need
    117        additional permissions. It appears appears in ci-configuration and
    118        various role and hook
    119        names.
    120    cb_name : string
    121        The name under which this function should be registered, defaulting to
    122        `name`.  Unlike `name`, which can appear multiple times, cb_name must be
    123        unique among all registered callbacks.
    124 
    125    Returns
    126    -------
    127    function
    128        To be used as decorator for the callback function.
    129    """
    130    mem = {"registered": False}  # workaround nonlocal missing in 2.x
    131 
    132    assert isinstance(title, str), "title must be a string"
    133    assert isinstance(description, str), "description must be a string"
    134    title = title.strip()
    135    description = description.strip()
    136 
    137    if not cb_name:
    138        cb_name = name
    139 
    140    # ensure that context is callable
    141    if not callable(context):
    142        context_value = context
    143 
    144        # Because of the same name as param it must be redefined
    145        # pylint: disable=E0102
    146        def context(params):
    147            return context_value  # noqa
    148 
    149    def register_callback(cb):
    150        assert isinstance(name, str), "name must be a string"
    151        assert isinstance(order, int), "order must be an integer"
    152        assert callable(schema) or is_json(schema), (
    153            "schema must be a JSON compatible object"
    154        )
    155        assert isinstance(cb, FunctionType), "callback must be a function"
    156        # Allow for json-e > 25 chars in the symbol.
    157        if "$" not in symbol:
    158            assert 1 <= len(symbol) <= 25, "symbol must be between 1 and 25 characters"
    159        assert isinstance(symbol, str), "symbol must be a string"
    160 
    161        assert not mem["registered"], (
    162            "register_callback_action must be used as decorator"
    163        )
    164        assert cb_name not in callbacks, f"callback name {cb_name} is not unique"
    165 
    166        def action_builder(parameters, graph_config, decision_task_id):
    167            if not available(parameters):
    168                return None
    169 
    170            # gather up the common decision-task-supplied data for this action
    171            repo_param = "{}head_repository".format(
    172                graph_config["project-repo-param-prefix"]
    173            )
    174            repository = {
    175                "url": parameters[repo_param],
    176                "project": parameters["project"],
    177                "level": parameters["level"],
    178            }
    179 
    180            revision = parameters[
    181                "{}head_rev".format(graph_config["project-repo-param-prefix"])
    182            ]
    183            base_revision = parameters[
    184                "{}base_rev".format(graph_config["project-repo-param-prefix"])
    185            ]
    186            push = {
    187                "owner": "mozilla-taskcluster-maintenance@mozilla.com",
    188                "pushlog_id": parameters["pushlog_id"],
    189                "revision": revision,
    190                "base_revision": base_revision,
    191            }
    192 
    193            action = {
    194                "name": name,
    195                "title": title,
    196                "description": description,
    197                # target taskGroupId (the task group this decision task is creating)
    198                "taskGroupId": decision_task_id,
    199                "cb_name": cb_name,
    200                "symbol": symbol,
    201            }
    202 
    203            rv = {
    204                "name": name,
    205                "title": title,
    206                "description": description,
    207                "context": context(parameters),
    208            }
    209            if schema:
    210                rv["schema"] = (
    211                    schema(graph_config=graph_config) if callable(schema) else schema
    212                )
    213 
    214            trustDomain = graph_config["trust-domain"]
    215            level = parameters["level"]
    216            tcyml_hash = hash_taskcluster_yml(graph_config.taskcluster_yml)
    217 
    218            # the tcyml_hash is prefixed with `/` in the hookId, so users will be granted
    219            # hooks:trigger-hook:project-gecko/in-tree-action-3-myaction/*; if another
    220            # action was named `myaction/release`, then the `*` in the scope would also
    221            # match that action.  To prevent such an accident, we prohibit `/` in hook
    222            # names.
    223            if "/" in permission:
    224                raise Exception("`/` is not allowed in action names; use `-`")
    225 
    226            rv.update({
    227                "kind": "hook",
    228                "hookGroupId": f"project-{trustDomain}",
    229                "hookId": f"in-tree-action-{level}-{permission}/{tcyml_hash}",
    230                "hookPayload": {
    231                    # provide the decision-task parameters as context for triggerHook
    232                    "decision": {
    233                        "action": action,
    234                        "repository": repository,
    235                        "push": push,
    236                    },
    237                    # and pass everything else through from our own context
    238                    "user": {
    239                        "input": {"$eval": "input"},
    240                        "taskId": {"$eval": "taskId"},  # target taskId (or null)
    241                        "taskGroupId": {"$eval": "taskGroupId"},  # target task group
    242                    },
    243                },
    244                "extra": {
    245                    "actionPerm": permission,
    246                },
    247            })
    248 
    249            return rv
    250 
    251        actions.append(Action(order, cb_name, permission, action_builder))
    252 
    253        mem["registered"] = True
    254        callbacks[cb_name] = cb
    255        return cb
    256 
    257    return register_callback
    258 
    259 
    260 def render_actions_json(parameters, graph_config, decision_task_id):
    261    """
    262    Render JSON object for the ``public/actions.json`` artifact.
    263 
    264    Parameters
    265    ----------
    266    parameters : taskgraph.parameters.Parameters
    267        Decision task parameters.
    268 
    269    Returns
    270    -------
    271    dict
    272        JSON object representation of the ``public/actions.json`` artifact.
    273    """
    274    assert isinstance(parameters, Parameters), "requires instance of Parameters"
    275    actions = []
    276    for action in sorted(_get_actions(graph_config), key=lambda action: action.order):
    277        action = action.action_builder(parameters, graph_config, decision_task_id)
    278        if action:
    279            assert is_json(action), "action must be a JSON compatible object"
    280            actions.append(action)
    281    return {
    282        "version": 1,
    283        "variables": {},
    284        "actions": actions,
    285    }
    286 
    287 
    288 def sanity_check_task_scope(callback, parameters, graph_config):
    289    """
    290    If this action is not generic, then verify that this task has the necessary
    291    scope to run the action. This serves as a backstop preventing abuse by
    292    running non-generic actions using generic hooks. While scopes should
    293    prevent serious damage from such abuse, it's never a valid thing to do.
    294    """
    295    for action in _get_actions(graph_config):
    296        if action.cb_name == callback:
    297            break
    298    else:
    299        raise Exception(f"No action with cb_name {callback}")
    300 
    301    repo_param = "{}head_repository".format(graph_config["project-repo-param-prefix"])
    302    head_repository = parameters[repo_param]
    303    assert head_repository.startswith("https://hg.mozilla.org/")
    304    expected_scope = f"assume:repo:{head_repository[8:]}:action:{action.permission}"
    305 
    306    # the scope should appear literally; no need for a satisfaction check. The use of
    307    # get_current_scopes here calls the auth service through the Taskcluster Proxy, giving
    308    # the precise scopes available to this task.
    309    if expected_scope not in taskcluster.get_current_scopes():
    310        raise Exception(f"Expected task scope {expected_scope} for this action")
    311 
    312 
    313 def trigger_action_callback(
    314    task_group_id, task_id, input, callback, parameters, root, test=False
    315 ):
    316    """
    317    Trigger action callback with the given inputs. If `test` is true, then run
    318    the action callback in testing mode, without actually creating tasks.
    319    """
    320    graph_config = load_graph_config(root)
    321    graph_config.register()
    322    callbacks = _get_callbacks(graph_config)
    323    cb = callbacks.get(callback, None)
    324    if not cb:
    325        raise Exception(
    326            "Unknown callback: {}. Known callbacks: {}".format(
    327                callback, ", ".join(callbacks)
    328            )
    329        )
    330 
    331    if test:
    332        create.testing = True
    333        taskcluster.testing = True
    334 
    335    if not test:
    336        sanity_check_task_scope(callback, parameters, graph_config)
    337 
    338    cb(Parameters(**parameters), graph_config, input, task_group_id, task_id)
    339 
    340 
    341 def _load(graph_config):
    342    # Load all modules from this folder, relying on the side-effects of register_
    343    # functions to populate the action registry.
    344    import_sibling_modules(exceptions=("util.py",))
    345    return callbacks, actions
    346 
    347 
    348 def _get_callbacks(graph_config):
    349    return _load(graph_config)[0]
    350 
    351 
    352 def _get_actions(graph_config):
    353    return _load(graph_config)[1]