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]