Bug 1385714 - Improve validation of action tasks draft
authorBrian Stack <bstack@mozilla.com>
Tue, 01 Aug 2017 15:44:09 -0700
changeset 619378 016d2b9e9a09586367665d042cf3c3bb0a38c2f6
parent 618576 87824406b9feb420a3150720707b424d7cee5915
child 640375 5cb106ed01222070e364eb7118598fa6a649a849
push id71656
push userbstack@mozilla.com
push dateTue, 01 Aug 2017 22:51:28 +0000
bugs1385714
milestone56.0a1
Bug 1385714 - Improve validation of action tasks MozReview-Commit-ID: AWNGipMlUl1
taskcluster/docs/actions-schema.yml
taskcluster/taskgraph/actions/add-new-jobs.py
taskcluster/taskgraph/actions/input-schema.json
taskcluster/taskgraph/actions/registry.py
taskcluster/taskgraph/actions/test-retrigger-action.py
--- a/taskcluster/docs/actions-schema.yml
+++ b/taskcluster/docs/actions-schema.yml
@@ -118,17 +118,17 @@ properties:
             consumer to decide.
 
             Notice that the `context` property is optional, but defined to have
             a default value `context: []`. Hence, if the `context` is not
             specified consumer should take this to mean `context: []` implying
             that the action is relevant to the task-group, rather than any
             subset of tasks.
         schema:
-          $ref: http://json-schema.org/schema
+          $ref: https://hg.mozilla.org/mozilla-central/raw-file/tip/taskcluster/taskgraph/actions/input-schema.json
           description: |
             JSON schema for input parameters to the `task` template property.
             Consumers shall offer a user-interface where end-users can enter
             values that satisfy this schema. Furthermore, consumers **must**
             validate enter values against the given schema before parameterizing
             the `task` template property and triggering the action.
 
             In practice it's encourage that consumers employ a facility that
--- a/taskcluster/taskgraph/actions/add-new-jobs.py
+++ b/taskcluster/taskgraph/actions/add-new-jobs.py
@@ -27,17 +27,18 @@ from taskgraph.taskgraph import TaskGrap
         'properties': {
             'tasks': {
                 'type': 'array',
                 'description': 'An array of task labels',
                 'items': {
                     'type': 'string'
                 }
             }
-        }
+        },
+        'additionalProperties': False
     }
 )
 def add_new_jobs_action(parameters, input, task_group_id, task_id, task):
     decision_task_id = find_decision_task(parameters)
 
     full_task_graph = get_artifact(decision_task_id, "public/full-task-graph.json")
     _, full_task_graph = TaskGraph.from_json(full_task_graph)
     label_to_taskid = get_artifact(decision_task_id, "public/label-to-taskid.json")
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/actions/input-schema.json
@@ -0,0 +1,164 @@
+{
+    "id": "https://hg.mozilla.org/mozilla-central/raw-file/tip/taskcluster/taskgraph/actions/input-schema.json",
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "description": "actions.json input schema, based on the core meta-schema but some extra requires",
+    "definitions": {
+        "schemaArray": {
+            "type": "array",
+            "minItems": 1,
+            "items": { "$ref": "#" }
+        },
+        "positiveInteger": {
+            "type": "integer",
+            "minimum": 0
+        },
+        "positiveIntegerDefault0": {
+            "allOf": [ { "$ref": "#/definitions/positiveInteger" }, { "default": 0 } ]
+        },
+        "simpleTypes": {
+            "enum": [ "array", "boolean", "integer", "null", "number", "string" ]
+        },
+        "stringArray": {
+            "type": "array",
+            "items": { "type": "string" },
+            "minItems": 1,
+            "uniqueItems": true
+        }
+    },
+    "type": "object",
+    "properties": {
+        "id": {
+            "type": "string",
+            "format": "uri"
+        },
+        "$schema": {
+            "type": "string",
+            "format": "uri"
+        },
+        "title": {
+            "type": "string"
+        },
+        "description": {
+            "type": "string"
+        },
+        "default": {},
+        "multipleOf": {
+            "type": "number",
+            "minimum": 0,
+            "exclusiveMinimum": true
+        },
+        "maximum": {
+            "type": "number"
+        },
+        "exclusiveMaximum": {
+            "type": "boolean",
+            "default": false
+        },
+        "minimum": {
+            "type": "number"
+        },
+        "exclusiveMinimum": {
+            "type": "boolean",
+            "default": false
+        },
+        "maxLength": { "$ref": "#/definitions/positiveInteger" },
+        "minLength": { "$ref": "#/definitions/positiveIntegerDefault0" },
+        "pattern": {
+            "type": "string",
+            "format": "regex"
+        },
+        "additionalItems": {
+            "anyOf": [
+                { "type": "boolean" },
+                { "$ref": "#" }
+            ],
+            "default": {}
+        },
+        "items": {
+            "anyOf": [
+                { "$ref": "#" },
+                { "$ref": "#/definitions/schemaArray" }
+            ],
+            "default": {}
+        },
+        "maxItems": { "$ref": "#/definitions/positiveInteger" },
+        "minItems": { "$ref": "#/definitions/positiveIntegerDefault0" },
+        "uniqueItems": {
+            "type": "boolean",
+            "default": false
+        },
+        "maxProperties": { "$ref": "#/definitions/positiveInteger" },
+        "minProperties": { "$ref": "#/definitions/positiveIntegerDefault0" },
+        "required": { "$ref": "#/definitions/stringArray" },
+        "additionalProperties": {
+            "anyOf": [
+                { "type": "boolean" },
+                { "$ref": "#" }
+            ],
+            "default": {}
+        },
+        "definitions": {
+            "type": "object",
+            "additionalProperties": { "$ref": "#" },
+            "default": {}
+        },
+        "properties": {
+            "type": "object",
+            "additionalProperties": { "$ref": "#" },
+            "default": {}
+        },
+        "patternProperties": {
+            "type": "object",
+            "additionalProperties": { "$ref": "#" },
+            "default": {}
+        },
+        "dependencies": {
+            "type": "object",
+            "additionalProperties": {
+                "anyOf": [
+                    { "$ref": "#" },
+                    { "$ref": "#/definitions/stringArray" }
+                ]
+            }
+        },
+        "type": {},
+        "enum": {
+            "type": "array",
+            "minItems": 1,
+            "uniqueItems": true
+        },
+        "allOf": { "$ref": "#/definitions/schemaArray" },
+        "anyOf": { "$ref": "#/definitions/schemaArray" },
+        "oneOf": { "$ref": "#/definitions/schemaArray" },
+        "not": { "$ref": "#" }
+    },
+    "oneOf": [
+      {
+        "properties": {
+          "type": {
+              "anyOf": [
+                  { "$ref": "#/definitions/simpleTypes" },
+                  {
+                      "type": "array",
+                      "items": { "$ref": "#/definitions/simpleTypes" },
+                      "minItems": 1,
+                      "uniqueItems": true
+                  }
+              ]
+          }
+        }
+      }, {
+        "properties": {
+          "type": { "enum": [ "object" ] }
+        },
+        "required": [ "additionalProperties" ]
+      }
+    ],
+    "required": [ "type" ],
+    "additionalProperties": false,
+    "dependencies": {
+        "exclusiveMaximum": [ "maximum" ],
+        "exclusiveMinimum": [ "minimum" ]
+    },
+    "default": {}
+}
--- a/taskcluster/taskgraph/actions/registry.py
+++ b/taskcluster/taskgraph/actions/registry.py
@@ -5,43 +5,57 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 from __future__ import absolute_import, print_function, unicode_literals
 
 import json
 import os
 import inspect
 import re
+from jsonschema import validate
+from jsonschema.exceptions import ValidationError
 from mozbuild.util import memoize
 from types import FunctionType
 from collections import namedtuple
 from taskgraph.util.docker import docker_image
 from taskgraph.parameters import Parameters
 from . import util
 
 
 GECKO = os.path.realpath(os.path.join(__file__, '..', '..', '..'))
+INPUT_SCHEMA = os.path.realpath(os.path.join(__file__, '..', 'input-schema.json'))
 
 actions = []
 callbacks = {}
+schemas = {}
 
 Action = namedtuple('Action', [
     'name', '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 assert_input_schema(data, name):
+    """ Throws if ``data`` is not a valid action input data structure. """
+    with open(INPUT_SCHEMA, 'r') as input_schema:
+        try:
+            validate(data, json.load(input_schema))
+        except ValidationError as e:
+            msg = 'Error reading input schema of action "{}". original error:\n{}'
+            raise Exception(msg.format(name, str(e)))
+
+
 def register_task_action(name, title, description, order, context, schema=None):
     """
     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`.
@@ -84,17 +98,18 @@ def register_task_action(name, title, de
         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(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 is_json(schema), 'schema must be a JSON compatible  object'
+    if schema:
+        assert_input_schema(schema, name)
     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(
             name.strip(), title.strip(), description.strip(), order, context,
             schema, task_template_builder,
         ))
@@ -248,16 +263,17 @@ ln -s /home/worker/artifacts artifacts &
                         'groupName': 'action-callback',
                         'groupSymbol': 'AC',
                         'symbol': symbol,
                     },
                 },
             }
         mem['registered'] = True
         callbacks[cb.__name__] = cb
+        schemas[cb.__name__] = schema
     return register_callback
 
 
 def render_actions_json(parameters):
     """
     Render JSON object for the ``public/actions.json`` artifact.
 
     Parameters
@@ -303,30 +319,38 @@ def trigger_action_callback(task_group_i
     Trigger action callback with the given inputs. If `test` is true, then run
     the action callback in testing mode, without actually creating tasks.
     """
     cb = get_callbacks().get(callback, None)
     if not cb:
         raise Exception('Unknown callback: {}. Known callbacks: {}'.format(
             callback, get_callbacks().keys()))
 
+    schema = get_schemas().get(callback, None)
+    if schema:
+        validate(input, schema)
+
     if test:
         util.testing = True
 
     cb(Parameters(**parameters), input, task_group_id, task_id, task)
 
 
 @memoize
 def _load():
     # 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', 'util.py'):
             __import__('taskgraph.actions.' + f[:-3])
-    return callbacks, actions
+    return callbacks, actions, schemas
 
 
 def get_callbacks():
     return _load()[0]
 
 
 def get_actions():
     return _load()[1]
+
+
+def get_schemas():
+    return _load()[2]
--- a/taskcluster/taskgraph/actions/test-retrigger-action.py
+++ b/taskcluster/taskgraph/actions/test-retrigger-action.py
@@ -64,23 +64,25 @@ logger = logging.getLogger(__name__)
                 'title': 'Run tests N times',
                 'description': ('Run tests repeatedly (usually used in '
                                 'conjunction with runUntilFail)')
             },
             'environment': {
                 'type': 'object',
                 'default': {'MOZ_LOG': ''},
                 'title': 'Extra environment variables',
-                'description': 'Extra environment variables to use for this run'
+                'description': 'Extra environment variables to use for this run',
+                'additionalProperties': {'type': 'string'}
             },
             'preferences': {
                 'type': 'object',
                 'default': {'mygeckopreferences.pref': 'myvalue2'},
                 'title': 'Extra gecko (about:config) preferences',
-                'description': 'Extra gecko (about:config) preferences to use for this run'
+                'description': 'Extra gecko (about:config) preferences to use for this run',
+                'additionalProperties': {'type': 'string'}
             }
         },
         'additionalProperties': False,
         'required': ['path']
     }
 )
 def test_retrigger_action(parameters, input, task_group_id, task_id, task):
     new_task_definition = copy.copy(task)