Bug 1415868 - add support for defining actions with kind=hook; r=jonasfj,tomprince
authorDustin J. Mitchell <dustin@mozilla.com>
Wed, 25 Apr 2018 17:56:29 +0000
changeset 475916 a891a10ca4d9258a2fe6298f70aa2960edf614c5
parent 475915 2c95df49455b5499bb1bbd5d174c32217daef61a
child 475917 4cbd35f872893c1426a7845f990d860a32c08710
push id1757
push userffxbld-merge
push dateFri, 24 Aug 2018 17:02:43 +0000
treeherdermozilla-release@736023aebdb1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjonasfj, tomprince
bugs1415868
milestone62.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1415868 - add support for defining actions with kind=hook; r=jonasfj,tomprince This does not affect any existing actions. MozReview-Commit-ID: 9j5cT2kA7UU
taskcluster/taskgraph/actions/registry.py
--- a/taskcluster/taskgraph/actions/registry.py
+++ b/taskcluster/taskgraph/actions/registry.py
@@ -17,32 +17,31 @@ from taskgraph import create
 from taskgraph.config import load_graph_config
 from taskgraph.util import taskcluster
 from taskgraph.parameters import Parameters
 
 
 actions = []
 callbacks = {}
 
-Action = namedtuple('Action', [
-    'name', 'title', 'description', 'order', 'context', 'schema', 'task_template_builder',
-])
+Action = namedtuple('Action', ['order', 'action_builder'])
 
 
 def is_json(data):
     """ Return ``True``, if ``data`` is a JSON serializable data structure. """
     try:
         json.dumps(data)
     except ValueError:
         return False
     return True
 
 
 def register_callback_action(name, title, symbol, description, order=10000,
-                             context=[], available=lambda parameters: True, schema=None):
+                             context=[], available=lambda parameters: True,
+                             schema=None, kind='task', generic=True):
     """
     Register an action callback that can be triggered from supporting
     user interfaces, such as Treeherder.
 
     This function is to be used as a decorator for a callback that takes
     parameters as follows:
 
     ``parameters``:
@@ -85,91 +84,155 @@ def register_callback_action(name, title
         ``task.tags.k == 'b' && task.tags.p = 'l'`` or ``task.tags.k = 't'``.
         Esentially, this allows filtering on ``task.tags``.
     available : function
         An optional function that given decision parameters decides if the
         action is available. Defaults to a function that always returns ``True``.
     schema : dict
         JSON schema specifying input accepted by the action.
         This is optional and can be left ``null`` if no input is taken.
+    kind : string
+        The action kind to define - must be one of `task` or `hook`.  Only for
+        transitional purposes.
+    generic : boolean
+        For kind=hook, whether this is a generic action or has its own permissions.
 
     Returns
     -------
     function
         To be used as decorator for the callback function.
     """
     mem = {"registered": False}  # workaround nonlocal missing in 2.x
 
+    assert isinstance(title, basestring), 'title must be a string'
+    assert isinstance(description, basestring), 'description must be a string'
+    title = title.strip()
+    description = description.strip()
+
     def register_callback(cb):
         assert isinstance(name, basestring), 'name must be a string'
-        assert isinstance(title, basestring), 'title must be a string'
-        assert isinstance(description, basestring), 'description must be a string'
         assert isinstance(order, int), 'order must be an integer'
+        assert kind in ('task', 'hook'), 'kind must be task or hook'
         assert callable(schema) or is_json(schema), 'schema must be a JSON compatible object'
         assert isinstance(cb, FunctionType), 'callback must be a function'
         # Allow for json-e > 25 chars in the symbol.
         if '$' not in symbol:
             assert 1 <= len(symbol) <= 25, 'symbol must be between 1 and 25 characters'
         assert isinstance(symbol, basestring), 'symbol must be a string'
 
         assert not mem['registered'], 'register_callback_action must be used as decorator'
         assert cb.__name__ not in callbacks, 'callback name {} is not unique'.format(cb.__name__)
 
-        def task_template_builder(parameters, graph_config):
+        def action_builder(parameters, graph_config):
             if not available(parameters):
                 return None
 
+            actionPerm = 'generic' if generic else name
+
+            # gather up the common decision-task-supplied data for this action
             repo_param = '{}head_repository'.format(graph_config['project-repo-param-prefix'])
+            repository = {
+                'url': parameters[repo_param],
+                'project': parameters['project'],
+                'level': parameters['level'],
+            }
+
             revision = parameters['{}head_rev'.format(graph_config['project-repo-param-prefix'])]
+            push = {
+                'owner': 'mozilla-taskcluster-maintenance@mozilla.com',
+                'pushlog_id': parameters['pushlog_id'],
+                'revision': revision,
+            }
+
+            task_group_id = os.environ.get('TASK_ID', slugid())
             match = re.match(r'https://(hg.mozilla.org)/(.*?)/?$', parameters[repo_param])
             if not match:
                 raise Exception('Unrecognized {}'.format(repo_param))
-            repo_scope = 'assume:repo:{}/{}:branch:default'.format(
-                match.group(1), match.group(2))
+            action = {
+                'name': name,
+                'title': title,
+                'description': description,
+                'taskGroupId': task_group_id,
+                'cb_name': cb.__name__,
+                'symbol': symbol,
+            }
 
-            task_group_id = os.environ.get('TASK_ID', slugid())
+            rv = {
+                'name': name,
+                'title': title,
+                'description': description,
+                'context': context,
+            }
+            if schema:
+                rv['schema'] = schema(graph_config=graph_config) if callable(schema) else schema
 
-            template = graph_config.taskcluster_yml
+            # for kind=task, we embed the task from .taskcluster.yml in the action, with
+            # suitable context
+            if kind == 'task':
+                template = graph_config.taskcluster_yml
 
-            with open(template, 'r') as f:
-                taskcluster_yml = yaml.safe_load(f)
-                if taskcluster_yml['version'] != 1:
-                    raise Exception('actions.json must be updated to work with .taskcluster.yml')
-                if not isinstance(taskcluster_yml['tasks'], list):
-                    raise Exception('.taskcluster.yml "tasks" must be a list for action tasks')
+                # tasks get all of the scopes the original push did, yuck; this is not
+                # done with kind = hook.
+                repo_scope = 'assume:repo:{}/{}:branch:default'.format(
+                    match.group(1), match.group(2))
+                action['repo_scope'] = repo_scope
 
-                return {
-                    '$let': {
-                        'tasks_for': 'action',
-                        'repository': {
-                            'url': parameters[repo_param],
-                            'project': parameters['project'],
-                            'level': parameters['level'],
+                with open(template, 'r') as f:
+                    taskcluster_yml = yaml.safe_load(f)
+                    if taskcluster_yml['version'] != 1:
+                        raise Exception(
+                            'actions.json must be updated to work with .taskcluster.yml')
+                    if not isinstance(taskcluster_yml['tasks'], list):
+                        raise Exception(
+                            '.taskcluster.yml "tasks" must be a list for action tasks')
+
+                rv.update({
+                    'kind': 'task',
+                    'task': {
+                        '$let': {
+                            'tasks_for': 'action',
+                            'repository': repository,
+                            'push': push,
+                            'action': action,
                         },
-                        'push': {
-                            'owner': 'mozilla-taskcluster-maintenance@mozilla.com',
-                            'pushlog_id': parameters['pushlog_id'],
-                            'revision': revision,
+                        'in': taskcluster_yml['tasks'][0],
+                    },
+                })
+
+            # for kind=hook
+            elif kind == 'hook':
+                trustDomain = graph_config['trust-domain']
+                level = parameters['level']
+                rv.update({
+                    'kind': 'hook',
+                    'hookGroupId': 'project-{}'.format(trustDomain),
+                    'hookId': 'in-tree-action-{}-{}'.format(level, actionPerm),
+                    'hookPayload': {
+                        # provide the decision-task parameters as context for triggerHook
+                        "decision": {
+                            'action': action,
+                            'repository': repository,
+                            'push': push,
+                            # parameters is long, so fetch it from the actions.json variables
+                            'parameters': {'$eval': 'parameters'},
                         },
-                        'action': {
-                            'name': name,
-                            'title': title,
-                            'description': description,
-                            'taskGroupId': task_group_id,
-                            'repo_scope': repo_scope,
-                            'cb_name': cb.__name__,
-                            'symbol': symbol,
-                        },
+
+                        # and pass everything else through from our own context
+                        "user": {
+                            'input': {'$eval': 'input'},
+                            'task': {'$eval': 'task'},
+                            'taskId': {'$eval': 'taskId'},
+                            'taskGroupId': {'$eval': 'taskGroupId'},
+                        }
                     },
-                    'in': taskcluster_yml['tasks'][0]
-                }
+                })
 
-        actions.append(Action(
-            name.strip(), title.strip(), description.strip(), order, context,
-            schema, task_template_builder))
+            return rv
+
+        actions.append(Action(order, action_builder))
 
         mem['registered'] = True
         callbacks[cb.__name__] = cb
     return register_callback
 
 
 def render_actions_json(parameters, graph_config):
     """
@@ -181,42 +244,28 @@ def render_actions_json(parameters, grap
         Decision task parameters.
 
     Returns
     -------
     dict
         JSON object representation of the ``public/actions.json`` artifact.
     """
     assert isinstance(parameters, Parameters), 'requires instance of Parameters'
-    result = []
+    actions = []
     for action in sorted(_get_actions(graph_config), key=lambda action: action.order):
-        task = action.task_template_builder(parameters, graph_config)
-        if task:
-            assert is_json(task), 'task must be a JSON compatible object'
-            res = {
-                'kind': 'task',
-                'name': action.name,
-                'title': action.title,
-                'description': action.description,
-                'context': action.context,
-                'schema': (
-                    action.schema(graph_config=graph_config) if callable(action.schema)
-                    else action.schema
-                ),
-                'task': task,
-            }
-            if res['schema'] is None:
-                res.pop('schema')
-            result.append(res)
+        action = action.action_builder(parameters, graph_config)
+        if action:
+            assert is_json(action), 'action must be a JSON compatible object'
+            actions.append(action)
     return {
         'version': 1,
         'variables': {
             'parameters': dict(**parameters),
         },
-        'actions': result,
+        'actions': actions,
     }
 
 
 def trigger_action_callback(task_group_id, task_id, task, input, callback, parameters, root,
                             test=False):
     """
     Trigger action callback with the given inputs. If `test` is true, then run
     the action callback in testing mode, without actually creating tasks.