--- 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)