diff --git a/doc/source/api-ref/v1/maintenance.inc b/doc/source/api-ref/v1/maintenance.inc index 96fb861..fecabe4 100644 --- a/doc/source/api-ref/v1/maintenance.inc +++ b/doc/source/api-ref/v1/maintenance.inc @@ -47,7 +47,7 @@ Response codes 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 session. diff --git a/doc/source/api-ref/v1/parameters.yaml b/doc/source/api-ref/v1/parameters.yaml index 1f28ba1..208cef9 100644 --- a/doc/source/api-ref/v1/parameters.yaml +++ b/doc/source/api-ref/v1/parameters.yaml @@ -17,9 +17,8 @@ session_id: description: | Session ID in: path - required: false + required: true type: string - min_version: \> 1 uuid-path: description: | @@ -68,7 +67,7 @@ action-plugins: description: | List of action plug-ins. in: body - required: true + required: false type: list of dictionaries boolean: @@ -90,7 +89,7 @@ hosts: Hosts to be maintained. An empty list can indicate hosts are to be discovered. in: body - required: true + required: false type: list of strings instance-action: @@ -102,7 +101,8 @@ instance-action: instance-actions: 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 required: true type: dictionary @@ -133,7 +133,10 @@ lead-time: 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 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 required: true type: integer diff --git a/doc/source/api-ref/v1/project.inc b/doc/source/api-ref/v1/project.inc index ab88cd9..ca14d95 100644 --- a/doc/source/api-ref/v1/project.inc +++ b/doc/source/api-ref/v1/project.inc @@ -127,7 +127,7 @@ Request - instance_id: uuid-path - instance_id: uuid - group_id: group-uuid - - name: instance-name + - instance_name: instance-name - migration_type: migration-type - max_interruption_time: max-interruption-time - resource_mitigation: resource-mitigation @@ -160,7 +160,7 @@ Request - instance_id: uuid-path - instance_id: uuid - group_id: group-uuid - - name: instance-name + - instance_name: instance-name - migration_type: migration-type - max_interruption_time: max-interruption-time - resource_mitigation: resource-mitigation @@ -218,7 +218,7 @@ Request - project_id: uuid - instance_id: uuid-path - instance_id: uuid - - name: instance-name + - group_name: instance-group - migration_type: migration-type - max_interruption_time: max-interruption-time - resource_mitigation: resource-mitigation @@ -250,7 +250,7 @@ Request - group_id: group-uuid-path - group_id: group-uuid - project_id: uuid - - name: instance-group + - group_name: instance-group - anti_affinity_group: boolean - max_instances_per_host: max-instances-per-host - max_impacted_members: max-impacted-members diff --git a/doc/source/api-ref/v1/samples/instance-group-constraints.json b/doc/source/api-ref/v1/samples/instance-group-constraints.json index 6297b42..403e382 100644 --- a/doc/source/api-ref/v1/samples/instance-group-constraints.json +++ b/doc/source/api-ref/v1/samples/instance-group-constraints.json @@ -1,8 +1,8 @@ { "project_id": "1ad1154137ac41799cefd5caebae379b", "group_id": "a01d192c-328e-4708-9b3c-9d716cd24a92", - "name": "vm_ha_group", - "anti_affinity_group": "True", + "group_name": "vm_ha_group", + "anti_affinity_group": True, "max_instances_per_host": 1, "max_impacted_members": 1, "recovery_time": 15, diff --git a/fenix/api/v1/controllers/maintenance.py b/fenix/api/v1/controllers/maintenance.py index 65b27a4..847bbeb 100644 --- a/fenix/api/v1/controllers/maintenance.py +++ b/fenix/api/v1/controllers/maintenance.py @@ -14,6 +14,7 @@ # under the License. import json +import jsonschema from pecan import abort from pecan import expose from pecan import request @@ -24,6 +25,7 @@ from oslo_log import log from oslo_serialization import jsonutils from fenix.api.v1 import maintenance +from fenix.api.v1 import schema from fenix import policy LOG = log.getLogger(__name__) @@ -43,6 +45,12 @@ class ProjectController(rest.RestController): if request.body: LOG.error("Unexpected data") 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, project_id) try: @@ -55,6 +63,13 @@ class ProjectController(rest.RestController): @expose(content_type='application/json') def put(self, session_id, project_id): 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, project_id, data) @@ -76,6 +91,16 @@ class ProjectInstanceController(rest.RestController): @expose(content_type='application/json') def put(self, session_id, project_id, instance_id): 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 = ( self.engine_rpcapi.project_update_session_instance(session_id, project_id, @@ -98,13 +123,18 @@ class SessionController(rest.RestController): @policy.authorize('maintenance:session', 'get') @expose(content_type='application/json') 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: LOG.error("Unexpected data") abort(400) session = self.engine_rpcapi.admin_get_session(session_id) if session is None: - response.status = 404 - return {"error": "Invalid session"} + LOG.error("Invalid session") + abort(404) try: response.text = jsonutils.dumps(session) except TypeError: @@ -115,6 +145,13 @@ class SessionController(rest.RestController): @expose(content_type='application/json') def put(self, session_id): 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) try: response.text = jsonutils.dumps(engine_data) @@ -125,6 +162,11 @@ class SessionController(rest.RestController): @policy.authorize('maintenance:session', 'delete') @expose(content_type='application/json') 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: LOG.error("Unexpected data") abort(400) @@ -160,10 +202,15 @@ class MaintenanceController(rest.RestController): @expose(content_type='application/json') def post(self): 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) if session is None: - response.status = 509 - return {"error": "Too many sessions"} + LOG.error("Too many sessions") + abort(509) try: response.text = jsonutils.dumps(session) except TypeError: @@ -181,13 +228,18 @@ class InstanceController(rest.RestController): @policy.authorize('instance', 'get') @expose(content_type='application/json') 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: LOG.error("Unexpected data") abort(400) session = self.engine_rpcapi.get_instance(instance_id) if session is None: - response.status = 404 - return {"error": "Invalid session"} + LOG.error("Invalid session") + abort(404) try: response.text = jsonutils.dumps(session) except TypeError: @@ -198,6 +250,12 @@ class InstanceController(rest.RestController): @expose(content_type='application/json') def put(self, instance_id): 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, data) try: @@ -209,6 +267,11 @@ class InstanceController(rest.RestController): @policy.authorize('instance', 'delete') @expose(content_type='application/json') 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: LOG.error("Unexpected data") abort(400) @@ -230,13 +293,18 @@ class InstanceGroupController(rest.RestController): @policy.authorize('instance_group', 'get') @expose(content_type='application/json') 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: LOG.error("Unexpected data") abort(400) session = self.engine_rpcapi.get_instance_group(group_id) if session is None: - response.status = 404 - return {"error": "Invalid session"} + LOG.error("Invalid session") + abort(404) try: response.text = jsonutils.dumps(session) except TypeError: @@ -247,6 +315,12 @@ class InstanceGroupController(rest.RestController): @expose(content_type='application/json') def put(self, group_id): 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 = ( self.engine_rpcapi.update_instance_group(group_id, data)) try: @@ -258,6 +332,11 @@ class InstanceGroupController(rest.RestController): @policy.authorize('instance_group', 'delete') @expose(content_type='application/json') 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: LOG.error("Unexpected data") abort(400) diff --git a/fenix/api/v1/schema.py b/fenix/api/v1/schema.py new file mode 100644 index 0000000..d041ac8 --- /dev/null +++ b/fenix/api/v1/schema.py @@ -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'] +} diff --git a/tox.ini b/tox.ini index d8f021e..fe9ee10 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] -minversion = 2.0 -envlist = py36,py35,pep8,docs -skipsdist = True +minversion = 3.1.1 +envlist = py36,pep8,docs +ignore_basepython_conflict = True [testenv] usedevelop = True @@ -13,7 +13,7 @@ setenv = OS_STDERR_CAPTURE=1 OS_TEST_TIMEOUT=60 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}/test-requirements.txt commands = stestr run {posargs}