import json import os import inspect from types import FunctionType from collections import namedtuple from taskgraph.util.docker import docker_image from taskgraph.parameters import Parameters GECKO = os.path.realpath(os.path.join(__file__, '..', '..', '..')) actions = [] callbacks = {} Action = namedtuple('Action', [ 'title', 'description', 'order', 'context', 'schema', 'task_template_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_task_action(title, description, order, context, schema): """ Register an action task that can be triggered from supporting user interfaces, such as Treeherder. Most actions will create intermediate action tasks that call back into in-tree python code. To write such an action please use :func:`register_callback_action`. This function is to be used a decorator for a function that returns a task template, see :doc:`specification ` for details on the templating features. The decorated function will be given decision task parameters, which can be embedded in the task template that is returned. Parameters ---------- title : str A human readable title for the action to be used as label on a button or text on a link for triggering the action. description : str A human readable description of the action in **markdown**. This will be display as tooltip and in dialog window when the action is triggered. This is a good place to describe how to use the action. order : int Order of the action in menus, this is relative to the ``order`` of other actions declared. context : list of dict List of tag-sets specifying which tasks the action is can take as input. If no tag-sets is specified as input the action is related to the entire task-group, and won't be triggered with a given task. Otherwise, if ``context = [{'k': 'b', 'p': 'l'}, {'k': 't'}]`` will only be displayed in the context menu for tasks that has ``task.tags.k == 'b' && task.tags.p = 'l'`` or ``task.tags.k = 't'``. Esentially, this allows filtering on ``task.tags``. schema : dict JSON schema specifying input accepted by the action. This is optional and can be left ``null`` if no input is taken. Returns ------- function To be used as decorator for the function that builds the task template. The decorated function will be given decision parameters and may return ``None`` instead of a task template, if the action is disabled. """ 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 is_json(schema), 'schema must be a JSON compatible object' mem = {"registered": False} # workaround nonlocal missing in 2.x def register_task_template_builder(task_template_builder): assert not mem['registered'], 'register_task_action must be used as decorator' actions.append(Action( title.strip(), description.strip(), order, context, schema, task_template_builder, )) mem['registered'] = True return register_task_template_builder def register_callback_action(title, symbol, description, order=10000, context=[], available=lambda parameters: True, schema=None): """ 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``: Decision task parameters, see ``taskgraph.parameters.Parameters``. ``input``: Input matching specified JSON schema, ``None`` if no ``schema`` parameter is given to ``register_callback_action``. ``task_group_id``: The id of the task-group this was triggered for. ``task_id`` and `task``: task identifier and task definition for task the action was triggered for, ``None`` if no ``context`` parameters was given to ``register_callback_action``. Parameters ---------- title : str A human readable title for the action to be used as label on a button or text on a link for triggering the action. symbol : str Treeherder symbol for the action callback, this is the symbol that the task calling your callback will be displayed as. This is usually 1-3 letters abbreviating the action title. description : str A human readable description of the action in **markdown**. This will be display as tooltip and in dialog window when the action is triggered. This is a good place to describe how to use the action. order : int Order of the action in menus, this is relative to the ``order`` of other actions declared. context : list of dict List of tag-sets specifying which tasks the action is can take as input. If no tag-sets is specified as input the action is related to the entire task-group, and won't be triggered with a given task. Otherwise, if ``context = [{'k': 'b', 'p': 'l'}, {'k': 't'}]`` will only be displayed in the context menu for tasks that has ``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. Returns ------- function To be used as decorator for the callback function. """ mem = {"registered": False} # workaround nonlocal missing in 2.x def register_callback(cb): assert isinstance(cb, FunctionType), 'callback must be a function' assert isinstance(symbol, basestring), 'symbol must be a string' assert 1 <= len(symbol) <= 25, 'symbol must be between 1 and 25 characters' 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__) source_path = os.path.relpath(inspect.stack()[1][1], GECKO) @register_task_action(title, description, order, context, schema) def build_callback_action_task(parameters): if not available(parameters): return None return { 'created': {'$fromNow': ''}, 'deadline': {'$fromNow': '12 hours'}, 'expires': {'$fromNow': '14 days'}, 'metadata': { 'owner': 'mozilla-taskcluster-maintenance@mozilla.com', 'source': '{}raw-file/{}/{}'.format( parameters['head_repository'], parameters['head_rev'], source_path, ), 'name': 'Action: {}'.format(title), 'description': 'Task executing callback for action.\n\n---\n' + description, }, 'workerType': 'gecko-decision', 'provisionerId': 'aws-provisioner-v1', 'scopes': [ 'assume:repo:hg.mozilla.org/projects/{}:*'.format(parameters['project']), ], 'tags': { 'createdForUser': parameters['owner'], 'kind': 'action-callback', }, 'routes': [ 'tc-treeherder.v2.{}.{}.{}'.format( parameters['project'], parameters['head_rev'], parameters['pushlog_id']), 'tc-treeherder-stage.v2.{}.{}.{}'.format( parameters['project'], parameters['head_rev'], parameters['pushlog_id']), ], 'payload': { 'env': { 'GECKO_BASE_REPOSITORY': 'https://hg.mozilla.org/mozilla-unified', 'GECKO_HEAD_REPOSITORY': parameters['head_repository'], 'GECKO_HEAD_REF': parameters['head_ref'], 'GECKO_HEAD_REV': parameters['head_rev'], 'HG_STORE_PATH': '/home/worker/checkouts/hg-store', 'ACTION_TASK_GROUP_ID': {'$eval': 'taskGroupId'}, 'ACTION_TASK_ID': {'$dumps': {'$eval': 'taskId'}}, 'ACTION_TASK': {'$dumps': {'$eval': 'task'}}, 'ACTION_INPUT': {'$dumps': {'$eval': 'input'}}, 'ACTION_CALLBACK': cb.__name__, 'ACTION_PARAMETERS': {'$dumps': {'$eval': 'parameters'}}, }, 'cache': { 'level-{}-checkouts'.format(parameters['level']): '/home/worker/checkouts', }, 'features': { 'taskclusterProxy': True, 'chainOfTrust': True, }, 'image': docker_image('decision'), 'maxRunTime': 1800, 'command': [ '/home/worker/bin/run-task', '--vcs-checkout=/home/worker/checkouts/gecko', '--', 'bash', '-cx', """\ cd /home/worker/checkouts/gecko && ln -s /home/worker/artifacts artifacts && ./mach --log-no-times taskgraph action-callback """ + ' '.join([ "--pushlog-id='{}'".format(parameters['pushlog_id']), "--pushdate='{}'".format(parameters['pushdate']), "--project='{}'".format(parameters['project']), "--message='{}'".format(parameters['message'].replace("'", "'\\''")), "--owner='{}'".format(parameters['owner']), "--level='{}'".format(parameters['level']), "--base-repository='https://hg.mozilla.org/mozilla-central'", "--head-repository='{}'".format(parameters['head_repository']), "--head-ref='{}'".format(parameters['head_ref']), "--head-rev='{}'".format(parameters['head_rev']), "--revision-hash='{}'\n".format(parameters['head_rev']), ]), ], }, 'extra': { 'treeherder': { 'groupName': 'action-callback', 'groupSymbol': 'AC', 'symbol': symbol, }, }, } mem['registered'] = True callbacks[cb.__name__] = cb return register_callback def render_actions_json(parameters): """ Render JSON object for the ``public/actions.json`` artifact. Parameters ---------- parameters : taskgraph.parameters.Parameters Decision task parameters. Returns ------- dict JSON object representation of the ``public/actions.json`` artifact. """ global actions assert isinstance(parameters, Parameters), 'requires instance of Parameters' result = [] for action in sorted(actions, key=lambda action: action.order): task = action.task_template_builder(parameters) if task: assert is_json(task), 'task must be a JSON compatible object' result.append({ 'title': action.title, 'description': action.description, 'context': action.context, 'schema': action.schema, 'task': task, }) return { 'version': 1, 'variables': { 'parameters': dict(**parameters), }, 'actions': result, } def trigger_action_callback(): """ Trigger action callback using arguments from environment variables. """ global callbacks task_group_id = os.environ.get('ACTION_TASK_GROUP_ID', None) task_id = json.loads(os.environ.get('ACTION_TASK_ID', 'null')) task = json.loads(os.environ.get('ACTION_TASK', 'null')) input = json.loads(os.environ.get('ACTION_INPUT', 'null')) callback = os.environ.get('ACTION_CALLBACK', None) parameters = json.loads(os.environ.get('ACTION_PARAMETERS', 'null')) cb = callbacks.get(callback, None) if not cb: raise Exception('Unknown callback: {}'.format(callback)) cb(Parameters(**parameters), input, task_group_id, task_id, task) # Load all modules from this folder, relying on the side-effects of register_ # functions to populate the action registry. for f in os.listdir(os.path.dirname(__file__)): if f.endswith('.py') and f not in ('__init__.py', 'registry.py'): __import__('actions.' + f[:-3])