Commit 3ef5878e authored by Daniele Venzano's avatar Daniele Venzano

Use JSON schema to validate ZApp descriptions

Update also the port description
parent c4a41385
......@@ -3,7 +3,7 @@
"size": 512,
"services": [
{
"docker_image": "docker-registry:5000/zapps/spark2-master",
"image": "docker-registry:5000/zapps/spark2-master",
"environment": [
[
"SPARK_MASTER_IP",
......@@ -23,12 +23,10 @@
"name": "spark-master",
"ports": [
{
"expose": true,
"is_main_endpoint": true,
"name": "Spark master web interface",
"path": "/",
"url_template": "http://{ip_port}/",
"port_number": 8080,
"protocol": "http"
"protocol": "tcp"
}
],
"required_resources": {
......@@ -38,7 +36,7 @@
"total_count": 1
},
{
"docker_image": "docker-registry:5000/zapps/spark2-worker",
"image": "docker-registry:5000/zapps/spark2-worker",
"environment": [
[
"SPARK_WORKER_CORES",
......@@ -70,11 +68,10 @@
"name": "spark-worker",
"ports": [
{
"is_main_endpoint": false,
"name": "Spark worker web interface",
"path": "/",
"url_template": "http://{ip_port}/",
"port_number": 8081,
"protocol": "http"
"protocol": "tcp"
}
],
"required_resources": {
......@@ -84,7 +81,7 @@
"total_count": 2
},
{
"docker_image": "docker-registry:5000/zapps/spark2-jupyter-notebook",
"image": "docker-registry:5000/zapps/spark2-jupyter-notebook",
"environment": [
[
"SPARK_MASTER",
......@@ -120,19 +117,16 @@
"name": "spark-jupyter",
"ports": [
{
"is_main_endpoint": false,
"name": "Spark application web interface",
"path": "/",
"url_template": "http://{ip_port}/",
"port_number": 4040,
"protocol": "http"
"protocol": "tcp"
},
{
"expose": true,
"is_main_endpoint": true,
"name": "Jupyter Notebook interface",
"path": "/",
"url_template": "http://{ip_port}/",
"port_number": 8888,
"protocol": "http"
"protocol": "tcp"
}
],
"required_resources": {
......@@ -142,6 +136,6 @@
"total_count": 1
}
],
"version": 2,
"version": 3,
"will_end": false
}
......@@ -9,6 +9,8 @@ A Zoe application description is a JSON document. Currently we generate them via
At the top level map there are some settings, mostly metadata, and a list of services. Each service has its own metadata and some docker-related parameters.
All fields are required.
Top level
---------
......@@ -17,42 +19,35 @@ A ZApp is completely contained in a JSON Object.
name
^^^^
required, string
string
The name of this Zapp. Do not confuse this with the name of the execution: you can have many executions (experiment-1, experiment-2) of the same ZApp.
version
^^^^^^^
required, number
number
The ZApp format version of this description. Zoe will check this value before trying to parse the rest of the ZApp to make sure it is able to correctly interpret the description.
will_end
^^^^^^^^
required, boolean
boolean
Must be set to False if potentially this application could run forever. For example a Jupyter notebook will never end (must be terminated explicitly by the user), so needs to have this value set to ``false``. A Spark job instead will finish by itself, so for batch ZApps set this value to ``true``.
size
^^^^
required, number >= 0
number >= 0
This value is used by the Elastic scheduler as an hint to the application size.
disable_autorestart
^^^^^^^^^^^^^^^^^^^
optional, boolean
If set to true, disables all kinds of auorestart on all services of this ZApp.
services
^^^^^^^^
required, array
array
The list of services to include in this ZApp.
......@@ -64,14 +59,14 @@ Each service is a JSON Object. At least one service needs to have the monitor ke
name
^^^^
required, string
string
The name of this service. This value will be combined with other information to generate the unique network names that can be used by services to talk to each other.
environment
^^^^^^^^^^^
required, array
array
Environment variables to be passed to the service/container. Each entry in the array must be an array with two elements, the variable name and its value.
......@@ -87,7 +82,7 @@ A number of special values can be used, these will be substituted by Zoe when th
volumes
^^^^^^^
optional, array
array
A list of additional volumes to be mounted in this service container. Each volume is described by an array with three elements:
......@@ -97,17 +92,17 @@ A list of additional volumes to be mounted in this service container. Each volum
Zoe will always mount the user workspace directory in ``$ZOE_WORKSPACE``.
docker_image
^^^^^^^^^^^^
image
^^^^^
required, string
string
The full name of the Docker image for this service. The registry can be local, but also images on the Docker Hub will work as expected.
monitor
^^^^^^^
required, boolean
boolean
If set to ``true``, Zoe will monitor this service for termination. When it terminates, Zoe will proceed killing all the other services of the same execution and set the execution status to ``termianted``.
If set to ``false``, Zoe will configure Docker to automatically restart the service in case it crashes.
......@@ -119,82 +114,70 @@ All autorestart behaviour is disabled if the global parameter ``disable_autorest
total_count
^^^^^^^^^^^
required, number
number
The maximum number of services of this type (with the same docker image and associated options) that can be started by Zoe.
essential_count
^^^^^^^^^^^^^^^
required, number <= total_count
number <= total_count
The minimum number of services of this type that Zoe must start before being able to consider the ZApp as started. For example, in Spark you need just one worker to produce useful work (essential_count equal to 1), but if there is the possibility of adding up to 9 more workers, the application will run faster (total_count equal to 10).
required_resources
^^^^^^^^^^^^^^^^^^
required, object
object
Resources that need to be reserved for this service. Currently only ``memory`` is supported, specified in bytes.
startup_order
^^^^^^^^^^^^^
required, number
number
Relative ordering for service startup. Zoe will start first services with a lower value. Note that Zoe will not wait for the service to be up and running before starting the next in the list.
ports
^^^^^
required, array
array
A list of ports that the user may wants to access. Currently this is tailored for web interfaces, URLs for each port will be shown in the client interfaces. See the *port* section below for details.
Ports
-----
Zoe will instruct the backend to expose ports on public addresses. This is usually done by port forwarding and depends on the capabilities of the configured back-end.
name
^^^^
required, string
string
A user friendly description for the service exposed on this port.
path
^^^^
url_template
^^^^^^^^^^^^
optional, string
string
The path part of the URL, after the port number. Must start with '/'.
A template for the full URL that will be exposed to the user. Zoe will query the backend at run time to get the public IP address and port combination and substitute the ``{ip_port}`` part.
protocol
^^^^^^^^
required, string
The URL protocol
is_main_endpoint
^^^^^^^^^^^^^^^^
required, boolean
Used to emphasize certain service endpoints in the user interface.
expose
^^^^^^
optional, boolean
string
Expose this port on a public IP address vie Docker. This feature in incomplete: it works only on TCP port and Zoe will not show anywhere the public IP address, that will be available only by using Docker tools.
The protocol, either ``tcp`` or ``udp``.
port_number
^^^^^^^^^^^
required, number
number
The port number where this service endpoint is exposed.
The port number where the service is listening for connections. The external (user-visible) port number will be chosen by the back-end.
Example
-------
......@@ -202,7 +185,7 @@ Example
{
"name": "Jupyter notebook",
"version": 2,
"version": 3,
"will_end": false,
"size": 512,
"services": [
......@@ -211,7 +194,7 @@ Example
"environment": [
["NB_USER", "{user_name}"]
],
"docker_image": "docker-registry:5000/apps/jupyter-notebook",
"image": "docker-registry:5000/apps/jupyter-notebook",
"monitor": true,
"total_count": 1,
"essential_count": 1,
......@@ -222,10 +205,8 @@ Example
"ports": [
{
"name": "Jupyter Notebook interface",
"path": "/",
"protocol": "http",
"is_main_endpoint": true,
"expose": true,
"url_template": "http://{ip_port}/",
"protocol": "tcp",
"port_number": 8888
}
]
......
......@@ -11,3 +11,4 @@ python-oauth2
python-consul
pykube>=0.14.0
sphinx
jsonschema
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Zoe application description schema",
"definitions": {
"service": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 3,
"maxLength": 16,
"pattern": "^[a-zA-Z0-9\\-]*$"
},
"image": {
"type": "string",
"minLength": 1
},
"ports": {
"type": "array",
"items": {
"$ref": "#/definitions/port"
},
"minItems": 0,
"uniqueItems": true
},
"environment": {
"type": "array",
"items": {
"$ref": "#/definitions/env_var"
},
"minItems": 0,
"uniqueItems": true
},
"volumes": {
"type": "array",
"items": {
"$ref": "#/definitions/volume"
},
"minItems": 0,
"uniqueItems": true
},
"monitor": {
"type": "boolean"
},
"startup_order": {
"type": "number",
"multipleOf": 1.0
},
"essential_count": {
"type": "number",
"multipleOf": 1.0,
"minimum": 1
},
"total_count": {
"type": "number",
"multipleOf": 1.0,
"minimum": 1
},
"resources": {
"type": "object",
"properties": {
"memory": {
"type": "object",
"properties": {
"min": {
"oneOf": [
{
"type": "number",
"multipleOf": 1.0,
"minimum": 1024
},
{
"type": "null"
}
]
},
"max": {
"oneOf": [
{
"type": "number",
"multipleOf": 1.0
},
{
"type": "null"
}
]
}
},
"additionalProperties": false,
"required": [
"min",
"max"
]
},
"cores": {
"type": "object",
"properties": {
"min": {
"oneOf": [
{
"type": "number",
"minimum": 0,
"exclusiveMinimum": true
},
{
"type": "null"
}
]
},
"max": {
"oneOf": [
{
"type": "number"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false,
"required": [
"min",
"max"
]
}
},
"additionalProperties": false,
"required": [
"memory",
"cores"
]
},
"command": {
"oneOf": [
{ "type": "string", "minLength": 1 },
{ "type": "null" }
]
}
},
"additionalProperties": false,
"required": ["name", "image", "ports", "environment", "volumes", "monitor", "startup_order", "essential_count", "total_count", "resources", "command"]
},
"port": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 3
},
"port_number": {
"type": "number",
"multipleOf": 1.0,
"minimum": 0,
"exclusiveMinimum": true,
"maximum": 65536,
"exclusiveMaximum": true
},
"protocol": {
"type": "string",
"enum": ["tcp", "udp"]
},
"url_template": {
"type": "string",
"regex": "^[a-z]?://HOST:PORT/[a-zA-Z0-9./\\-_]$"
}
},
"additionalProperties": false,
"required": ["name", "port_number", "protocol", "url_template"]
},
"env_var": {
"type": "array",
"items": [
{
"type": "string",
"regex": "^[A-Z][A-Z0-9]*$"
},
{
"type": "string"
}
]
},
"volume": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 3,
"regex": "^[a-zA-Z0-9.]?$"
},
"path": {
"type": "string",
"minLength": 2,
"regex": "^/[a-zA-Z0-9]?[a-zA-Z0-9./]*$"
},
"read_only": {
"type": "boolean"
}
}
}
},
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 3,
"maxLength": 16,
"pattern": "^[a-zA-Z0-9\\-]*$"
},
"will_end": {
"type": "boolean"
},
"size": {
"type": "integer",
"minimum": 0
},
"version": {
"type": "number",
"multipleOf": 1.0,
"minimum": 3
},
"services": {
"type": "array",
"items": {
"$ref": "#/definitions/service"
},
"minItems": 1,
"uniqueItems": true
}
},
"additionalProperties": false,
"required": ["name", "will_end", "size", "version", "services"]
}
......@@ -172,3 +172,18 @@ class APIEndpoint:
self.master.execution_terminate(execution.id)
break
log.debug('Cleanup task finished')
def execution_endpoints(self, uid: str, role: str, execution: zoe_lib.state.Execution):
"""Return a list of the services and public endpoints available for a certain execution."""
services_info = []
endpoints = []
for service in execution.services:
services_info.append(self.service_by_id(uid, role, service.id))
port_mappings = service.ports
for port in service.description['ports']:
port_number = str(port['port_number']) + "/" + port['protocol']
if port_number in port_mappings:
endpoint = port['url_template'].format(**{"ip_port": port_mappings[port_number][0] + ":" + port_mappings[port_number][1]})
endpoints.append((port['name'], endpoint))
return services_info, endpoints
......@@ -19,7 +19,7 @@ from typing import List
import tornado.web
from zoe_api.rest_api.execution import ExecutionAPI, ExecutionCollectionAPI, ExecutionDeleteAPI
from zoe_api.rest_api.execution import ExecutionAPI, ExecutionCollectionAPI, ExecutionDeleteAPI, ExecutionEndpointsAPI
from zoe_api.rest_api.info import InfoAPI
from zoe_api.rest_api.userinfo import UserInfoAPI
from zoe_api.rest_api.service import ServiceAPI
......@@ -46,6 +46,7 @@ def api_init(api_endpoint) -> List[tornado.web.URLSpec]:
tornado.web.url(API_PATH + r'/execution/([0-9]+)', ExecutionAPI, route_args),
tornado.web.url(API_PATH + r'/execution/delete/([0-9]+)', ExecutionDeleteAPI, route_args),
tornado.web.url(API_PATH + r'/execution/endpoints/([0-9]+)', ExecutionEndpointsAPI, route_args),
tornado.web.url(API_PATH + r'/execution', ExecutionCollectionAPI, route_args),
tornado.web.url(API_PATH + r'/service/([0-9]+)', ServiceAPI, route_args),
......
......@@ -117,7 +117,7 @@ class ExecutionCollectionAPI(RequestHandler):
manage_cors_headers(self)
@catch_exceptions
def options(self): # pylint: disable=unused-argument
def options(self):
"""Needed for CORS."""
self.set_status(204)
self.finish()
......@@ -175,3 +175,39 @@ class ExecutionCollectionAPI(RequestHandler):
def data_received(self, chunk):
"""Not implemented as we do not use stream uploads"""
pass
class ExecutionEndpointsAPI(RequestHandler):
"""The ExecutionEndpoints API endpoint."""
def initialize(self, **kwargs):
"""Initializes the request handler."""
self.api_endpoint = kwargs['api_endpoint'] # type: APIEndpoint
def set_default_headers(self):
"""Set up the headers for enabling CORS."""
manage_cors_headers(self)
@catch_exceptions
def options(self):
"""Needed for CORS."""
self.set_status(204)
self.finish()
@catch_exceptions
def get(self, execution_id: int):
"""
Get a list of execution endpoints.
:param execution_id: the execution to be deleted
"""
uid, role = get_auth(self)
execution = self.api_endpoint.execution_by_id(uid, role, execution_id)
services_, endpoints = self.api_endpoint.execution_endpoints(uid, role, execution)
self.write({'endpoints': endpoints})
def data_received(self, chunk):
"""Not implemented as we do not use stream uploads"""
pass
......@@ -105,9 +105,11 @@ def exec_get_cmd(args):
print('Execution not found')
else:
print('Execution {} (ID: {})'.format(execution['name'], execution['id']))
print('Application name: {}'.format(execution['description']['name']))
print('Status: {}'.format(execution['status']))
if execution['status'] == 'error':
print('Last error: {}'.format(execution['error_message']))
print()
print('Time submit: {}'.format(datetime.datetime.fromtimestamp(execution['time_submit'])))
if execution['time_start'] is None:
......@@ -119,9 +121,17 @@ def exec_get_cmd(args):
print('Time end: {}'.format('not yet'))
else:
print('Time end: {}'.format(datetime.datetime.fromtimestamp(execution['time_end'])))
print()
app = execution['description']
print('Application name: {}'.format(app['name']))
endpoints = exec_api.endpoints(execution['id'])
if len(endpoints) > 0:
print('Exposed endpoints:')
else:
print('This ZApp does not expose any endpoint')
for endpoint in endpoints:
print(' - {}: {}'.format(endpoint[0], endpoint[1]))
print()
for c_id in execution['services']:
service = cont_api.get(c_id)
print('Service {} (ID: {})'.format(service['name'], service['id']))
......@@ -129,10 +139,6 @@ def exec_get_cmd(args):
print(' - backend status: {}'.format(service['backend_status']))
if service['error_message'] is not None:
print(' - error: {}'.format(service['error_message']))
if service['backend_status'] == 'started':
ip = service['ip_address']
for port in service['description']['ports']:
print(' - {}: {}://{}:{}{}'.format(port['name'], port['protocol'], ip, port['port_number'], port['path']))
def exec_kill_cmd(args):
......
......@@ -18,9 +18,11 @@ This module contains code to validate application descriptions.
"""
import logging
import re
import json
from zoe_lib.exceptions import InvalidApplicationDescription
import jsonschema
from zoe_lib.exceptions import InvalidApplicationDescription, ZoeLibException
import zoe_lib.version
log = logging.getLogger(__name__)
......@@ -29,147 +31,35 @@ log = logging.getLogger(__name__)
def app_validate(data):
"""
Validates an application description, making sure all required fields are present and of the correct type.
This validation is also performed on the Zoe Master side.
If the description is not valid, an InvalidApplicationDescription exception is thrown.
Uses a JSON schema definition.
:param data: a dictionary containing an application description
:param data: an open file descriptor containing JSON data
:return: None if the application description is correct
"""
required_keys = ['name', 'will_end', 'size', 'version']
for k in required_keys:
if k not in data:
raise InvalidApplicationDescription(msg="Missing required key: %s" % k)
try:
ver = int(data["version"])
if ver != zoe_lib.version.ZOE_APPLICATION_FORMAT_VERSION:
raise InvalidApplicationDescription(msg="This version of Zoe supports only version {} for application descriptions".format(zoe_lib.version.ZOE_APPLICATION_FORMAT_VERSION))
except ValueError:
raise InvalidApplicationDescription(msg="version field should be an int")
try:
bool(data['will_end'])
except ValueError:
raise InvalidApplicationDescription(msg="will_end field must be a boolean")
schema = json.load(open('schemas/app_description_schema.json', 'r'))
try:
size = int(data['size'])
except ValueError:
raise InvalidApplicationDescription(msg="size field must be an int")
if size < 0:
raise InvalidApplicationDescription(msg="size must be between 0 and 1024")
jsonschema.validate(data, schema)
except jsonschema.ValidationError as e:
raise InvalidApplicationDescription(str(e))
except jsonschema.SchemaError:
log.exception('BUG: invalid schema for application descriptions')
raise ZoeLibException('BUG: invalid schema for application descriptions')
if 'services' not in data: