Bug 1415868 - add support for defining actions with kind=hook; r?jonasfj,tomprince draft
authorDustin J. Mitchell <dustin@mozilla.com>
Wed, 25 Apr 2018 17:56:29 +0000
changeset 797331 a4939c981528de4337173dfb4cfa0c0ca7128f6c
parent 797330 1c7bb608a7e0e9ee537f55e0c17d3b88f37c4ee3
child 797332 1ebe5befd1964018d1ec46c6e7c28a1bb84e5da4
push id110465
push userdmitchell@mozilla.com
push dateFri, 18 May 2018 22:17:50 +0000
reviewersjonasfj, tomprince
bugs1415868
milestone62.0a1
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.