API schema validation

Story: 2007278
Task: #38717

Change-Id: I7a6fc62e8f2c0c3cc21560f9f889d0fe136ca33e
Signed-off-by: Tomi Juvonen <tomi.juvonen@nokia.com>
This commit is contained in:
Tomi Juvonen 2020-02-10 12:03:38 +02:00
parent e6b796f6b7
commit e3004fec86
7 changed files with 309 additions and 25 deletions

View File

@ -47,7 +47,7 @@ Response codes
Update maintenance session (planned future functionality) Update maintenance session (planned future functionality)
========================================================= =========================================================
.. rest_method:: POST /v1/maintenance/{session_id}/ .. rest_method:: PUT /v1/maintenance/{session_id}/
Update existing maintenance session. This can be used to continue a failed Update existing maintenance session. This can be used to continue a failed
session. session.

View File

@ -17,9 +17,8 @@ session_id:
description: | description: |
Session ID Session ID
in: path in: path
required: false required: true
type: string type: string
min_version: \> 1
uuid-path: uuid-path:
description: | description: |
@ -68,7 +67,7 @@ action-plugins:
description: | description: |
List of action plug-ins. List of action plug-ins.
in: body in: body
required: true required: false
type: list of dictionaries type: list of dictionaries
boolean: boolean:
@ -90,7 +89,7 @@ hosts:
Hosts to be maintained. An empty list can indicate hosts are to be Hosts to be maintained. An empty list can indicate hosts are to be
discovered. discovered.
in: body in: body
required: true required: false
type: list of strings type: list of strings
instance-action: instance-action:
@ -102,7 +101,8 @@ instance-action:
instance-actions: instance-actions:
description: | description: |
instance ID : action string instance ID : action string. This variable is not needed in reply to state
MAINTENANCE, SCALE_IN or MAINTENANCE_COMPLETE
in: body in: body
required: true required: true
type: dictionary type: dictionary
@ -133,7 +133,10 @@ lead-time:
How long lead time VNF needs for 'migration_type' operation. VNF needs to How long lead time VNF needs for 'migration_type' operation. VNF needs to
report back to Fenix as soon as it is ready, but at least within this report back to Fenix as soon as it is ready, but at least within this
time. Reporting as fast as can is crucial for optimizing time. Reporting as fast as can is crucial for optimizing
infrastructure upgrade/maintenance. infrastructure upgrade/maintenance. Zero value means interaction with
VNFM is not used for this instance, but instance_group recovery_time
needs to be obeyed towards max_impacted_members.
in: body in: body
required: true required: true
type: integer type: integer

View File

@ -127,7 +127,7 @@ Request
- instance_id: uuid-path - instance_id: uuid-path
- instance_id: uuid - instance_id: uuid
- group_id: group-uuid - group_id: group-uuid
- name: instance-name - instance_name: instance-name
- migration_type: migration-type - migration_type: migration-type
- max_interruption_time: max-interruption-time - max_interruption_time: max-interruption-time
- resource_mitigation: resource-mitigation - resource_mitigation: resource-mitigation
@ -160,7 +160,7 @@ Request
- instance_id: uuid-path - instance_id: uuid-path
- instance_id: uuid - instance_id: uuid
- group_id: group-uuid - group_id: group-uuid
- name: instance-name - instance_name: instance-name
- migration_type: migration-type - migration_type: migration-type
- max_interruption_time: max-interruption-time - max_interruption_time: max-interruption-time
- resource_mitigation: resource-mitigation - resource_mitigation: resource-mitigation
@ -218,7 +218,7 @@ Request
- project_id: uuid - project_id: uuid
- instance_id: uuid-path - instance_id: uuid-path
- instance_id: uuid - instance_id: uuid
- name: instance-name - group_name: instance-group
- migration_type: migration-type - migration_type: migration-type
- max_interruption_time: max-interruption-time - max_interruption_time: max-interruption-time
- resource_mitigation: resource-mitigation - resource_mitigation: resource-mitigation
@ -250,7 +250,7 @@ Request
- group_id: group-uuid-path - group_id: group-uuid-path
- group_id: group-uuid - group_id: group-uuid
- project_id: uuid - project_id: uuid
- name: instance-group - group_name: instance-group
- anti_affinity_group: boolean - anti_affinity_group: boolean
- max_instances_per_host: max-instances-per-host - max_instances_per_host: max-instances-per-host
- max_impacted_members: max-impacted-members - max_impacted_members: max-impacted-members

View File

@ -1,8 +1,8 @@
{ {
"project_id": "1ad1154137ac41799cefd5caebae379b", "project_id": "1ad1154137ac41799cefd5caebae379b",
"group_id": "a01d192c-328e-4708-9b3c-9d716cd24a92", "group_id": "a01d192c-328e-4708-9b3c-9d716cd24a92",
"name": "vm_ha_group", "group_name": "vm_ha_group",
"anti_affinity_group": "True", "anti_affinity_group": True,
"max_instances_per_host": 1, "max_instances_per_host": 1,
"max_impacted_members": 1, "max_impacted_members": 1,
"recovery_time": 15, "recovery_time": 15,

View File

@ -14,6 +14,7 @@
# under the License. # under the License.
import json import json
import jsonschema
from pecan import abort from pecan import abort
from pecan import expose from pecan import expose
from pecan import request from pecan import request
@ -24,6 +25,7 @@ from oslo_log import log
from oslo_serialization import jsonutils from oslo_serialization import jsonutils
from fenix.api.v1 import maintenance from fenix.api.v1 import maintenance
from fenix.api.v1 import schema
from fenix import policy from fenix import policy
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
@ -43,6 +45,12 @@ class ProjectController(rest.RestController):
if request.body: if request.body:
LOG.error("Unexpected data") LOG.error("Unexpected data")
abort(400) abort(400)
try:
jsonschema.validate(session_id, schema.uid)
jsonschema.validate(project_id, schema.uid)
except jsonschema.exceptions.ValidationError as e:
LOG.error(str(e.message))
abort(422)
engine_data = self.engine_rpcapi.project_get_session(session_id, engine_data = self.engine_rpcapi.project_get_session(session_id,
project_id) project_id)
try: try:
@ -55,6 +63,13 @@ class ProjectController(rest.RestController):
@expose(content_type='application/json') @expose(content_type='application/json')
def put(self, session_id, project_id): def put(self, session_id, project_id):
data = json.loads(request.body.decode('utf8')) data = json.loads(request.body.decode('utf8'))
try:
jsonschema.validate(session_id, schema.uid)
jsonschema.validate(project_id, schema.uid)
jsonschema.validate(data, schema.maintenance_session_project_put)
except jsonschema.exceptions.ValidationError as e:
LOG.error(str(e.message))
abort(422)
engine_data = self.engine_rpcapi.project_update_session(session_id, engine_data = self.engine_rpcapi.project_update_session(session_id,
project_id, project_id,
data) data)
@ -76,6 +91,16 @@ class ProjectInstanceController(rest.RestController):
@expose(content_type='application/json') @expose(content_type='application/json')
def put(self, session_id, project_id, instance_id): def put(self, session_id, project_id, instance_id):
data = json.loads(request.body.decode('utf8')) data = json.loads(request.body.decode('utf8'))
try:
jsonschema.validate(session_id, schema.uid)
jsonschema.validate(project_id, schema.uid)
jsonschema.validate(instance_id, schema.uid)
jsonschema.validate(
data,
schema.maintenance_session_project_instance_put)
except jsonschema.exceptions.ValidationError as e:
LOG.error(str(e.message))
abort(422)
engine_data = ( engine_data = (
self.engine_rpcapi.project_update_session_instance(session_id, self.engine_rpcapi.project_update_session_instance(session_id,
project_id, project_id,
@ -98,13 +123,18 @@ class SessionController(rest.RestController):
@policy.authorize('maintenance:session', 'get') @policy.authorize('maintenance:session', 'get')
@expose(content_type='application/json') @expose(content_type='application/json')
def get(self, session_id): def get(self, session_id):
try:
jsonschema.validate(session_id, schema.uid)
except jsonschema.exceptions.ValidationError as e:
LOG.error(str(e.message))
abort(422)
if request.body: if request.body:
LOG.error("Unexpected data") LOG.error("Unexpected data")
abort(400) abort(400)
session = self.engine_rpcapi.admin_get_session(session_id) session = self.engine_rpcapi.admin_get_session(session_id)
if session is None: if session is None:
response.status = 404 LOG.error("Invalid session")
return {"error": "Invalid session"} abort(404)
try: try:
response.text = jsonutils.dumps(session) response.text = jsonutils.dumps(session)
except TypeError: except TypeError:
@ -115,6 +145,13 @@ class SessionController(rest.RestController):
@expose(content_type='application/json') @expose(content_type='application/json')
def put(self, session_id): def put(self, session_id):
data = json.loads(request.body.decode('utf8')) data = json.loads(request.body.decode('utf8'))
try:
jsonschema.validate(session_id, schema.uid)
# TBD implement this API
# jsonschema.validate(data, schema.maintenance_session_put)
except jsonschema.exceptions.ValidationError as e:
LOG.error(str(e.message))
abort(422)
engine_data = self.engine_rpcapi.admin_update_session(session_id, data) engine_data = self.engine_rpcapi.admin_update_session(session_id, data)
try: try:
response.text = jsonutils.dumps(engine_data) response.text = jsonutils.dumps(engine_data)
@ -125,6 +162,11 @@ class SessionController(rest.RestController):
@policy.authorize('maintenance:session', 'delete') @policy.authorize('maintenance:session', 'delete')
@expose(content_type='application/json') @expose(content_type='application/json')
def delete(self, session_id): def delete(self, session_id):
try:
jsonschema.validate(session_id, schema.uid)
except jsonschema.exceptions.ValidationError as e:
LOG.error(str(e.message))
abort(422)
if request.body: if request.body:
LOG.error("Unexpected data") LOG.error("Unexpected data")
abort(400) abort(400)
@ -160,10 +202,15 @@ class MaintenanceController(rest.RestController):
@expose(content_type='application/json') @expose(content_type='application/json')
def post(self): def post(self):
data = json.loads(request.body.decode('utf8')) data = json.loads(request.body.decode('utf8'))
try:
jsonschema.validate(data, schema.maintenance_post)
except jsonschema.exceptions.ValidationError as e:
LOG.error(str(e.message))
abort(422)
session = self.engine_rpcapi.admin_create_session(data) session = self.engine_rpcapi.admin_create_session(data)
if session is None: if session is None:
response.status = 509 LOG.error("Too many sessions")
return {"error": "Too many sessions"} abort(509)
try: try:
response.text = jsonutils.dumps(session) response.text = jsonutils.dumps(session)
except TypeError: except TypeError:
@ -181,13 +228,18 @@ class InstanceController(rest.RestController):
@policy.authorize('instance', 'get') @policy.authorize('instance', 'get')
@expose(content_type='application/json') @expose(content_type='application/json')
def get(self, instance_id): def get(self, instance_id):
try:
jsonschema.validate(instance_id, schema.uid)
except jsonschema.exceptions.ValidationError as e:
LOG.error(str(e.message))
abort(422)
if request.body: if request.body:
LOG.error("Unexpected data") LOG.error("Unexpected data")
abort(400) abort(400)
session = self.engine_rpcapi.get_instance(instance_id) session = self.engine_rpcapi.get_instance(instance_id)
if session is None: if session is None:
response.status = 404 LOG.error("Invalid session")
return {"error": "Invalid session"} abort(404)
try: try:
response.text = jsonutils.dumps(session) response.text = jsonutils.dumps(session)
except TypeError: except TypeError:
@ -198,6 +250,12 @@ class InstanceController(rest.RestController):
@expose(content_type='application/json') @expose(content_type='application/json')
def put(self, instance_id): def put(self, instance_id):
data = json.loads(request.body.decode('utf8')) data = json.loads(request.body.decode('utf8'))
try:
jsonschema.validate(instance_id, schema.uid)
jsonschema.validate(data, schema.instance_put)
except jsonschema.exceptions.ValidationError as e:
LOG.error(str(e.message))
abort(422)
engine_data = self.engine_rpcapi.update_instance(instance_id, engine_data = self.engine_rpcapi.update_instance(instance_id,
data) data)
try: try:
@ -209,6 +267,11 @@ class InstanceController(rest.RestController):
@policy.authorize('instance', 'delete') @policy.authorize('instance', 'delete')
@expose(content_type='application/json') @expose(content_type='application/json')
def delete(self, instance_id): def delete(self, instance_id):
try:
jsonschema.validate(instance_id, schema.uid)
except jsonschema.exceptions.ValidationError as e:
LOG.error(str(e.message))
abort(422)
if request.body: if request.body:
LOG.error("Unexpected data") LOG.error("Unexpected data")
abort(400) abort(400)
@ -230,13 +293,18 @@ class InstanceGroupController(rest.RestController):
@policy.authorize('instance_group', 'get') @policy.authorize('instance_group', 'get')
@expose(content_type='application/json') @expose(content_type='application/json')
def get(self, group_id): def get(self, group_id):
try:
jsonschema.validate(group_id, schema.uid)
except jsonschema.exceptions.ValidationError as e:
LOG.error(str(e.message))
abort(422)
if request.body: if request.body:
LOG.error("Unexpected data") LOG.error("Unexpected data")
abort(400) abort(400)
session = self.engine_rpcapi.get_instance_group(group_id) session = self.engine_rpcapi.get_instance_group(group_id)
if session is None: if session is None:
response.status = 404 LOG.error("Invalid session")
return {"error": "Invalid session"} abort(404)
try: try:
response.text = jsonutils.dumps(session) response.text = jsonutils.dumps(session)
except TypeError: except TypeError:
@ -247,6 +315,12 @@ class InstanceGroupController(rest.RestController):
@expose(content_type='application/json') @expose(content_type='application/json')
def put(self, group_id): def put(self, group_id):
data = json.loads(request.body.decode('utf8')) data = json.loads(request.body.decode('utf8'))
try:
jsonschema.validate(group_id, schema.uid)
jsonschema.validate(data, schema.instance_group_put)
except jsonschema.exceptions.ValidationError as e:
LOG.error(str(e.message))
abort(422)
engine_data = ( engine_data = (
self.engine_rpcapi.update_instance_group(group_id, data)) self.engine_rpcapi.update_instance_group(group_id, data))
try: try:
@ -258,6 +332,11 @@ class InstanceGroupController(rest.RestController):
@policy.authorize('instance_group', 'delete') @policy.authorize('instance_group', 'delete')
@expose(content_type='application/json') @expose(content_type='application/json')
def delete(self, group_id): def delete(self, group_id):
try:
jsonschema.validate(group_id, schema.uid)
except jsonschema.exceptions.ValidationError as e:
LOG.error(str(e.message))
abort(422)
if request.body: if request.body:
LOG.error("Unexpected data") LOG.error("Unexpected data")
abort(400) abort(400)

202
fenix/api/v1/schema.py Normal file
View File

@ -0,0 +1,202 @@
# Copyright 2020 OpenStack Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
uid = {
'type': 'string',
'minLength': 8,
'maxLength': 36,
}
states = ['MAINTENANCE',
'SCALE_IN',
'PREPARE_MAINTENANCE',
'START_MAINTENANCE',
'PLANNED_MAINTENANCE',
'MAINTENANCE_COMPLETE',
'MAINTENANCE_DONE',
'MAINTENANCE_FAILED']
reply_states = ['ACK_MAINTENANCE',
'ACK_SCALE_IN',
'ACK_PREPARE_MAINTENANCE',
'ACK_START_MAINTENANCE',
'ACK_PLANNED_MAINTENANCE',
'ACK_MAINTENANCE_COMPLETE',
'NACK_MAINTENANCE',
'NACK_SCALE_IN',
'NACK_PREPARE_MAINTENANCE',
'NACK_START_MAINTENANCE',
'NACK_PLANNED_MAINTENANCE',
'NACK_MAINTENANCE_COMPLETE']
allowed_actions = ['MIGRATE', 'LIVE_MIGRATE', 'OWN_ACTION']
maintenance_session_project_put = {
'type': 'object',
'properties': {
'instance_actions': {
'type': 'object'
},
'state': {
'type': 'string',
'enum': reply_states,
},
},
'required': ['state']
}
maintenance_session_project_instance_put = {
'type': 'object',
'properties': {
'instance_action': {
'type': 'string',
'enum': allowed_actions,
},
'state': {
'type': 'string',
'enum': reply_states,
}
},
'required': ['instance_action', 'state']
}
# TBD
# maintenance_session_put = {
#
# }
maintenance_post = {
'type': 'object',
'properties': {
'hosts': {
'type': 'array',
'minItems': 0,
'maxItems': 1000,
'items': {
'type': 'string',
'minLength': 2,
'maxLength': 255,
},
},
'state': {
'type': 'string',
'enum': states,
},
'maintenance_at': {
'type': 'string',
'format': 'date-time',
},
'metadata': {'type': 'object'},
'workflow': {
'type': 'string',
'minLength': 2,
'maxLength': 255,
},
'download': {
'type': 'array',
'minItems': 5,
'maxItems': 255,
'items': {
'type': 'string',
'minLength': 2,
'maxLength': 165,
},
},
'actions': {
'type': 'array',
'minItems': 0,
'maxItems': 255,
'items': {
'type': 'object',
'properties': {
'plugin': {
'type': 'string',
'minLength': 2,
'maxLength': 255,
},
'type': {
'type': 'string',
'minLength': 2,
'maxLength': 32,
},
'metadata': {'type': 'object'},
},
'required': ['plugin', 'type']
}
}
},
'required': ['state', 'maintenance_at', 'workflow', 'metadata']
}
instance_put = {
'type': 'object',
'properties': {
'instance_id': uid,
'project_id': uid,
'group_id': uid,
'instance_name': {
'type': 'string',
'minLength': 1,
'maxLength': 255,
},
'max_interruption_time': {
'type': 'number',
'maximum': 21600
},
'migration_type': {
'type': 'string',
'enum': allowed_actions,
},
'resource_mitigation': {'type': 'boolean'},
'lead_time': {
'type': 'number',
'maximum': 21600
},
},
'required': ['instance_id', 'project_id', 'group_id', 'instance_name',
'max_interruption_time', 'migration_type',
'resource_mitigation', 'lead_time']
}
instance_group_put = {
'type': 'object',
'properties': {
'project_id': uid,
'group_id': uid,
'group_name': {
'type': 'string',
'minLength': 1,
'maxLength': 255,
},
'anti_affinity_group': {'type': 'boolean'},
'max_instances_per_host': {
'type': 'number',
'maximum': 32000
},
'max_impacted_members': {
'type': 'number',
'minimum': 1,
'maximum': 32000
},
'recovery_time': {
'type': 'number',
'maximum': 21600
},
'resource_mitigation': {'type': 'boolean'},
},
'required': ['project_id', 'group_id', 'group_name',
'anti_affinity_group', 'max_instances_per_host',
'max_impacted_members', 'recovery_time',
'resource_mitigation']
}

View File

@ -1,7 +1,7 @@
[tox] [tox]
minversion = 2.0 minversion = 3.1.1
envlist = py36,py35,pep8,docs envlist = py36,pep8,docs
skipsdist = True ignore_basepython_conflict = True
[testenv] [testenv]
usedevelop = True usedevelop = True
@ -13,7 +13,7 @@ setenv =
OS_STDERR_CAPTURE=1 OS_STDERR_CAPTURE=1
OS_TEST_TIMEOUT=60 OS_TEST_TIMEOUT=60
deps = deps =
-c{env:UPPER_CONSTRAINTS_FILE:https://opendev.org/openstack/requirements/raw/branch/master/upper-constraints.txt} -c{env:UPPER_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master}
-r{toxinidir}/requirements.txt -r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt -r{toxinidir}/test-requirements.txt
commands = stestr run {posargs} commands = stestr run {posargs}