diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 9448a71b8..d835cc748 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -191,6 +191,13 @@ audit_autotrigger: in: body required: false type: boolean +audit_endtime: + description: | + The time after which audit can't be executed. + in: body + required: false + type: string + min_version: 1.1 audit_goal: description: | The UUID or name of the Goal. @@ -229,6 +236,13 @@ audit_parameters: in: body required: false type: JSON +audit_starttime: + description: | + The time after which audit can be executed in accordance with interval. + in: body + required: false + type: string + min_version: 1.1 audit_state: description: | State of this audit. To get more information about states and diff --git a/api-ref/source/samples/audit-cancel-response.json b/api-ref/source/samples/audit-cancel-response.json index 886109acb..4d6cd3b13 100644 --- a/api-ref/source/samples/audit-cancel-response.json +++ b/api-ref/source/samples/audit-cancel-response.json @@ -48,5 +48,7 @@ "strategy_name": "workload_stabilization", "next_run_time": "2018-04-06T11:56:00", "updated_at": "2018-04-06T11:54:01.266447+00:00", - "hostname": "controller" -} \ No newline at end of file + "hostname": "controller", + "start_time": null, + "end_time": null +} diff --git a/api-ref/source/samples/audit-create-request-continuous.json b/api-ref/source/samples/audit-create-request-continuous.json index 4d5202260..aa3dac451 100644 --- a/api-ref/source/samples/audit-create-request-continuous.json +++ b/api-ref/source/samples/audit-create-request-continuous.json @@ -8,5 +8,7 @@ ] }, "audit_type": "CONTINUOUS", - "interval": "*/2 * * * *" -} \ No newline at end of file + "interval": "*/2 * * * *", + "start_time":"2018-04-02 20:30:00", + "end_time": "2018-04-04 20:30:00" +} diff --git a/api-ref/source/samples/audit-create-response.json b/api-ref/source/samples/audit-create-response.json index e4dcccbe4..8e31e74da 100644 --- a/api-ref/source/samples/audit-create-response.json +++ b/api-ref/source/samples/audit-create-response.json @@ -48,5 +48,7 @@ "strategy_name": "workload_stabilization", "next_run_time": null, "updated_at": null, - "hostname": null -} \ No newline at end of file + "hostname": null, + "start_time": null, + "end_time": null +} diff --git a/api-ref/source/samples/audit-list-detailed-response.json b/api-ref/source/samples/audit-list-detailed-response.json index f96da65d1..e86a3f01b 100644 --- a/api-ref/source/samples/audit-list-detailed-response.json +++ b/api-ref/source/samples/audit-list-detailed-response.json @@ -50,7 +50,9 @@ "strategy_name": "workload_stabilization", "next_run_time": "2018-04-06T09:46:00", "updated_at": "2018-04-06T09:44:01.604146+00:00", - "hostname": "controller" + "hostname": "controller", + "start_time": null, + "end_time": null } ] -} \ No newline at end of file +} diff --git a/api-ref/source/samples/audit-show-response.json b/api-ref/source/samples/audit-show-response.json index c8ddb28f5..39257a68f 100644 --- a/api-ref/source/samples/audit-show-response.json +++ b/api-ref/source/samples/audit-show-response.json @@ -48,5 +48,7 @@ "strategy_name": "workload_stabilization", "next_run_time": "2018-04-06T11:56:00", "updated_at": "2018-04-06T11:54:01.266447+00:00", - "hostname": "controller" -} \ No newline at end of file + "hostname": "controller", + "start_time": null, + "end_time": null +} diff --git a/api-ref/source/samples/audit-update-response.json b/api-ref/source/samples/audit-update-response.json index 886109acb..4d6cd3b13 100644 --- a/api-ref/source/samples/audit-update-response.json +++ b/api-ref/source/samples/audit-update-response.json @@ -48,5 +48,7 @@ "strategy_name": "workload_stabilization", "next_run_time": "2018-04-06T11:56:00", "updated_at": "2018-04-06T11:54:01.266447+00:00", - "hostname": "controller" -} \ No newline at end of file + "hostname": "controller", + "start_time": null, + "end_time": null +} diff --git a/api-ref/source/watcher-api-v1-audits.inc b/api-ref/source/watcher-api-v1-audits.inc index a4a990b0a..eda922af0 100644 --- a/api-ref/source/watcher-api-v1-audits.inc +++ b/api-ref/source/watcher-api-v1-audits.inc @@ -46,6 +46,8 @@ Request - interval: audit_interval - scope: audittemplate_scope - auto_trigger: audit_autotrigger + - start_time: audit_starttime + - end_time: audit_endtime **Example ONESHOT Audit creation request:** @@ -80,6 +82,8 @@ version 1: - scope: audittemplate_scope - links: links - hostname: audit_hostname + - start_time: audit_starttime + - end_time: audit_endtime **Example JSON representation of an Audit:** @@ -176,6 +180,8 @@ Response - scope: audittemplate_scope - links: links - hostname: audit_hostname + - start_time: audit_starttime + - end_time: audit_endtime **Example JSON representation of an Audit:** @@ -220,6 +226,8 @@ Response - scope: audittemplate_scope - links: links - hostname: audit_hostname + - start_time: audit_starttime + - end_time: audit_endtime **Example JSON representation of an Audit:** @@ -272,6 +280,8 @@ version 1: - scope: audittemplate_scope - links: links - hostname: audit_hostname + - start_time: audit_starttime + - end_time: audit_endtime **Example JSON representation of an Audit:** @@ -324,6 +334,8 @@ Response - scope: audittemplate_scope - links: links - hostname: audit_hostname + - start_time: audit_starttime + - end_time: audit_endtime **Example JSON representation of an Audit:** @@ -347,4 +359,4 @@ Request .. rest_parameters:: parameters.yaml - - audit_ident: audit_ident \ No newline at end of file + - audit_ident: audit_ident diff --git a/lower-constraints.txt b/lower-constraints.txt index 7f5c3474b..99567f6bd 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -57,6 +57,7 @@ lxml==4.1.1 Mako==1.0.7 MarkupSafe==1.0 mccabe==0.2.1 +microversion_parse==0.2.1 mock==2.0.0 monotonic==1.4 mox3==0.25.0 diff --git a/releasenotes/notes/api-microversioning-7999a3ee8073bf32.yaml b/releasenotes/notes/api-microversioning-7999a3ee8073bf32.yaml new file mode 100644 index 000000000..691c6731d --- /dev/null +++ b/releasenotes/notes/api-microversioning-7999a3ee8073bf32.yaml @@ -0,0 +1,8 @@ +--- +features: + Watcher starts to support API microversions since Stein cycle. From now + onwards all API changes should be made with saving backward compatibility. + To specify API version operator should use OpenStack-API-Version + HTTP header. If operator wants to know the mininum and maximum supported + versions by API, he/she can access /v1 resource and Watcher API will + return appropriate headers in response. diff --git a/requirements.txt b/requirements.txt index 669ae8516..39d429b2c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,4 +46,4 @@ taskflow>=3.1.0 # Apache-2.0 WebOb>=1.7.4 # MIT WSME>=0.9.2 # MIT networkx>=1.11 # BSD - +microversion_parse>=0.2.1 # Apache-2.0 diff --git a/watcher/api/controllers/base.py b/watcher/api/controllers/base.py index 54b5c3fc1..462c256c7 100644 --- a/watcher/api/controllers/base.py +++ b/watcher/api/controllers/base.py @@ -14,7 +14,10 @@ # limitations under the License. import datetime +import functools +import microversion_parse +from webob import exc import wsme from wsme import types as wtypes @@ -49,3 +52,84 @@ class APIBase(wtypes.Base): for k in self.as_dict(): if k not in except_list: setattr(self, k, wsme.Unset) + + +@functools.total_ordering +class Version(object): + """API Version object.""" + + string = 'OpenStack-API-Version' + """HTTP Header string carrying the requested version""" + + min_string = 'OpenStack-API-Minimum-Version' + """HTTP response header""" + + max_string = 'OpenStack-API-Maximum-Version' + """HTTP response header""" + + def __init__(self, headers, default_version, latest_version): + """Create an API Version object from the supplied headers. + + :param headers: webob headers + :param default_version: version to use if not specified in headers + :param latest_version: version to use if latest is requested + :raises: webob.HTTPNotAcceptable + + """ + (self.major, self.minor) = Version.parse_headers( + headers, default_version, latest_version) + + def __repr__(self): + return '%s.%s' % (self.major, self.minor) + + @staticmethod + def parse_headers(headers, default_version, latest_version): + """Determine the API version requested based on the headers supplied. + + :param headers: webob headers + :param default_version: version to use if not specified in headers + :param latest_version: version to use if latest is requested + :returns: a tuple of (major, minor) version numbers + :raises: webob.HTTPNotAcceptable + + """ + version_str = microversion_parse.get_version( + headers, + service_type='infra-optim') + + minimal_version = (1, 0) + + if version_str is None: + # If requested header is wrong, Watcher answers with the minimal + # supported version. + return minimal_version + + if version_str.lower() == 'latest': + parse_str = latest_version + else: + parse_str = version_str + + try: + version = tuple(int(i) for i in parse_str.split('.')) + except ValueError: + version = minimal_version + + # NOTE (alexchadin): Old python-watcherclient sends requests with + # value of version header is "1". It should be transformed to 1.0 as + # it was supposed to be. + if len(version) == 1 and version[0] == 1: + version = minimal_version + + if len(version) != 2: + raise exc.HTTPNotAcceptable( + "Invalid value for %s header" % Version.string) + return version + + def __gt__(self, other): + return (self.major, self.minor) > (other.major, other.minor) + + def __eq__(self, other): + return (self.major, self.minor) == (other.major, other.minor) + + def __ne__(self, other): + return not self.__eq__(other) diff --git a/watcher/api/controllers/root.py b/watcher/api/controllers/root.py index e42734c99..d9ac5b7eb 100644 --- a/watcher/api/controllers/root.py +++ b/watcher/api/controllers/root.py @@ -14,6 +14,8 @@ # License for the specific language governing permissions and limitations # under the License. +import importlib + import pecan from pecan import rest from wsme import types as wtypes @@ -24,19 +26,39 @@ from watcher.api.controllers import link from watcher.api.controllers import v1 +class APIStatus(object): + CURRENT = "CURRENT" + SUPPORTED = "SUPPORTED" + DEPRECATED = "DEPRECATED" + EXPERIMENTAL = "EXPERIMENTAL" + + class Version(base.APIBase): """An API version representation.""" id = wtypes.text """The ID of the version, also acts as the release number""" + status = wtypes.text + """The state of this API version""" + + max_version = wtypes.text + """The maximum version supported""" + + min_version = wtypes.text + """The minimum version supported""" + links = [link.Link] """A Link that point to a specific version of the API""" @staticmethod - def convert(id): + def convert(id, status=APIStatus.CURRENT): + v = importlib.import_module('watcher.api.controllers.%s.versions' % id) version = Version() version.id = id + version.status = status + version.max_version = v.max_version_string() + version.min_version = v.min_version_string() version.links = [link.Link.make_link('self', pecan.request.host_url, id, '', bookmark=True)] return version diff --git a/watcher/api/controllers/v1/__init__.py b/watcher/api/controllers/v1/__init__.py index 16279551e..b3250bee8 100644 --- a/watcher/api/controllers/v1/__init__.py +++ b/watcher/api/controllers/v1/__init__.py @@ -24,10 +24,12 @@ import datetime import pecan from pecan import rest +from webob import exc import wsme from wsme import types as wtypes import wsmeext.pecan as wsme_pecan +from watcher.api.controllers import base from watcher.api.controllers import link from watcher.api.controllers.v1 import action from watcher.api.controllers.v1 import action_plan @@ -37,6 +39,21 @@ from watcher.api.controllers.v1 import goal from watcher.api.controllers.v1 import scoring_engine from watcher.api.controllers.v1 import service from watcher.api.controllers.v1 import strategy +from watcher.api.controllers.v1 import versions + + +def min_version(): + return base.Version( + {base.Version.string: ' '.join([versions.service_type_string(), + versions.min_version_string()])}, + versions.min_version_string(), versions.max_version_string()) + + +def max_version(): + return base.Version( + {base.Version.string: ' '.join([versions.service_type_string(), + versions.max_version_string()])}, + versions.min_version_string(), versions.max_version_string()) class APIBase(wtypes.Base): @@ -193,5 +210,50 @@ class Controller(rest.RestController): # the request object to make the links. return V1.convert() + def _check_version(self, version, headers=None): + if headers is None: + headers = {} + # ensure that major version in the URL matches the header + if version.major != versions.BASE_VERSION: + raise exc.HTTPNotAcceptable( + "Mutually exclusive versions requested. Version %(ver)s " + "requested but not supported by this service. The supported " + "version range is: [%(min)s, %(max)s]." % + {'ver': version, 'min': versions.min_version_string(), + 'max': versions.max_version_string()}, + headers=headers) + # ensure the minor version is within the supported range + if version < min_version() or version > max_version(): + raise exc.HTTPNotAcceptable( + "Version %(ver)s was requested but the minor version is not " + "supported by this service. The supported version range is: " + "[%(min)s, %(max)s]." % + {'ver': version, 'min': versions.min_version_string(), + 'max': versions.max_version_string()}, + headers=headers) + + @pecan.expose() + def _route(self, args, request=None): + v = base.Version(pecan.request.headers, versions.min_version_string(), + versions.max_version_string()) + + # The Vary header is used as a hint to caching proxies and user agents + # that the response is also dependent on the OpenStack-API-Version and + # not just the body and query parameters. See RFC 7231 for details. + pecan.response.headers['Vary'] = base.Version.string + + # Always set the min and max headers + pecan.response.headers[base.Version.min_string] = ( + versions.min_version_string()) + pecan.response.headers[base.Version.max_string] = ( + versions.max_version_string()) + + # assert that requested version is supported + self._check_version(v, pecan.response.headers) + pecan.response.headers[base.Version.string] = str(v) + pecan.request.version = v + + return super(Controller, self)._route(args, request) + __all__ = ("Controller", ) diff --git a/watcher/api/controllers/v1/action.py b/watcher/api/controllers/v1/action.py index baadf418c..0aae7bf44 100644 --- a/watcher/api/controllers/v1/action.py +++ b/watcher/api/controllers/v1/action.py @@ -74,6 +74,16 @@ from watcher.common import policy from watcher import objects +def hide_fields_in_newer_versions(obj): + """This method hides fields that were added in newer API versions. + + Certain node fields were introduced at certain API versions. + These fields are only made available when the request's API version + matches or exceeds the versions when these fields were introduced. + """ + pass + + class ActionPatchType(types.JsonPatchType): @staticmethod @@ -174,6 +184,8 @@ class Action(base.APIBase): description = "" setattr(action, 'description', description) + hide_fields_in_newer_versions(action) + return cls._convert_with_links(action, pecan.request.host_url, expand) @classmethod diff --git a/watcher/api/controllers/v1/action_plan.py b/watcher/api/controllers/v1/action_plan.py index 1eb2cc4d3..1dd1c0a98 100644 --- a/watcher/api/controllers/v1/action_plan.py +++ b/watcher/api/controllers/v1/action_plan.py @@ -80,6 +80,16 @@ from watcher.objects import action_plan as ap_objects LOG = log.getLogger(__name__) +def hide_fields_in_newer_versions(obj): + """This method hides fields that were added in newer API versions. + + Certain node fields were introduced at certain API versions. + These fields are only made available when the request's API version + matches or exceeds the versions when these fields were introduced. + """ + pass + + class ActionPlanPatchType(types.JsonPatchType): @staticmethod @@ -273,6 +283,7 @@ class ActionPlan(base.APIBase): @classmethod def convert_with_links(cls, rpc_action_plan, expand=True): action_plan = ActionPlan(**rpc_action_plan.as_dict()) + hide_fields_in_newer_versions(action_plan) return cls._convert_with_links(action_plan, pecan.request.host_url, expand) diff --git a/watcher/api/controllers/v1/audit.py b/watcher/api/controllers/v1/audit.py index e5c721ae0..993f7f504 100644 --- a/watcher/api/controllers/v1/audit.py +++ b/watcher/api/controllers/v1/audit.py @@ -63,6 +63,18 @@ def _get_object_by_value(context, class_name, value): return class_name.get_by_name(context, value) +def hide_fields_in_newer_versions(obj): + """This method hides fields that were added in newer API versions. + + Certain node fields were introduced at certain API versions. + These fields are only made available when the request's API version + matches or exceeds the versions when these fields were introduced. + """ + if not api_utils.allow_start_end_audit_time(): + obj.start_time = wsme.Unset + obj.end_time = wsme.Unset + + class AuditPostType(wtypes.Base): name = wtypes.wsattr(wtypes.text, mandatory=False) @@ -116,6 +128,11 @@ class AuditPostType(wtypes.Base): raise exception.AuditStartEndTimeNotAllowed( audit_type=self.audit_type) + if not api_utils.allow_start_end_audit_time(): + for field in ('start_time', 'end_time'): + if getattr(self, field) not in (wsme.Unset, None): + raise exception.NotAcceptable() + # If audit_template_uuid was provided, we will provide any # variables not included in the request, but not override # those variables that were included. @@ -388,6 +405,7 @@ class Audit(base.APIBase): @classmethod def convert_with_links(cls, rpc_audit, expand=True): audit = Audit(**rpc_audit.as_dict()) + hide_fields_in_newer_versions(audit) return cls._convert_with_links(audit, pecan.request.host_url, expand) @classmethod diff --git a/watcher/api/controllers/v1/audit_template.py b/watcher/api/controllers/v1/audit_template.py index ffc2c0e8b..757654c95 100644 --- a/watcher/api/controllers/v1/audit_template.py +++ b/watcher/api/controllers/v1/audit_template.py @@ -65,6 +65,16 @@ from watcher.decision_engine.loading import default as default_loading from watcher import objects +def hide_fields_in_newer_versions(obj): + """This method hides fields that were added in newer API versions. + + Certain node fields were introduced at certain API versions. + These fields are only made available when the request's API version + matches or exceeds the versions when these fields were introduced. + """ + pass + + class AuditTemplatePostType(wtypes.Base): _ctx = context_utils.make_context() @@ -410,6 +420,7 @@ class AuditTemplate(base.APIBase): @classmethod def convert_with_links(cls, rpc_audit_template, expand=True): audit_template = AuditTemplate(**rpc_audit_template.as_dict()) + hide_fields_in_newer_versions(audit_template) return cls._convert_with_links(audit_template, pecan.request.host_url, expand) diff --git a/watcher/api/controllers/v1/goal.py b/watcher/api/controllers/v1/goal.py index 8d98d7c63..831031272 100644 --- a/watcher/api/controllers/v1/goal.py +++ b/watcher/api/controllers/v1/goal.py @@ -48,6 +48,16 @@ from watcher.common import policy from watcher import objects +def hide_fields_in_newer_versions(obj): + """This method hides fields that were added in newer API versions. + + Certain node fields were introduced at certain API versions. + These fields are only made available when the request's API version + matches or exceeds the versions when these fields were introduced. + """ + pass + + class Goal(base.APIBase): """API representation of a goal. @@ -97,6 +107,7 @@ class Goal(base.APIBase): @classmethod def convert_with_links(cls, goal, expand=True): goal = Goal(**goal.as_dict()) + hide_fields_in_newer_versions(goal) return cls._convert_with_links(goal, pecan.request.host_url, expand) @classmethod diff --git a/watcher/api/controllers/v1/scoring_engine.py b/watcher/api/controllers/v1/scoring_engine.py index 4cf6a98b2..ee7323e02 100644 --- a/watcher/api/controllers/v1/scoring_engine.py +++ b/watcher/api/controllers/v1/scoring_engine.py @@ -43,6 +43,16 @@ from watcher.common import policy from watcher import objects +def hide_fields_in_newer_versions(obj): + """This method hides fields that were added in newer API versions. + + Certain node fields were introduced at certain API versions. + These fields are only made available when the request's API version + matches or exceeds the versions when these fields were introduced. + """ + pass + + class ScoringEngine(base.APIBase): """API representation of a scoring engine. @@ -95,6 +105,7 @@ class ScoringEngine(base.APIBase): @classmethod def convert_with_links(cls, scoring_engine, expand=True): scoring_engine = ScoringEngine(**scoring_engine.as_dict()) + hide_fields_in_newer_versions(scoring_engine) return cls._convert_with_links( scoring_engine, pecan.request.host_url, expand) diff --git a/watcher/api/controllers/v1/service.py b/watcher/api/controllers/v1/service.py index 734975e17..9401031d0 100644 --- a/watcher/api/controllers/v1/service.py +++ b/watcher/api/controllers/v1/service.py @@ -44,6 +44,16 @@ CONF = cfg.CONF LOG = log.getLogger(__name__) +def hide_fields_in_newer_versions(obj): + """This method hides fields that were added in newer API versions. + + Certain node fields were introduced at certain API versions. + These fields are only made available when the request's API version + matches or exceeds the versions when these fields were introduced. + """ + pass + + class Service(base.APIBase): """API representation of a service. @@ -126,6 +136,7 @@ class Service(base.APIBase): @classmethod def convert_with_links(cls, service, expand=True): service = Service(**service.as_dict()) + hide_fields_in_newer_versions(service) return cls._convert_with_links( service, pecan.request.host_url, expand) diff --git a/watcher/api/controllers/v1/strategy.py b/watcher/api/controllers/v1/strategy.py index 06a2a9347..cc89a09a5 100644 --- a/watcher/api/controllers/v1/strategy.py +++ b/watcher/api/controllers/v1/strategy.py @@ -45,6 +45,16 @@ from watcher.decision_engine import rpcapi from watcher import objects +def hide_fields_in_newer_versions(obj): + """This method hides fields that were added in newer API versions. + + Certain node fields were introduced at certain API versions. + These fields are only made available when the request's API version + matches or exceeds the versions when these fields were introduced. + """ + pass + + class Strategy(base.APIBase): """API representation of a strategy. @@ -146,6 +156,7 @@ class Strategy(base.APIBase): @classmethod def convert_with_links(cls, strategy, expand=True): strategy = Strategy(**strategy.as_dict()) + hide_fields_in_newer_versions(strategy) return cls._convert_with_links( strategy, pecan.request.host_url, expand) diff --git a/watcher/api/controllers/v1/utils.py b/watcher/api/controllers/v1/utils.py index e9e8fadfd..55e83d001 100644 --- a/watcher/api/controllers/v1/utils.py +++ b/watcher/api/controllers/v1/utils.py @@ -23,6 +23,7 @@ import pecan import wsme from watcher._i18n import _ +from watcher.api.controllers.v1 import versions from watcher.common import utils from watcher import objects @@ -155,3 +156,12 @@ def get_resource(resource, resource_id, eager=False): return _get(pecan.request.context, resource_id, eager=eager) return _get(pecan.request.context, resource_id) + + +def allow_start_end_audit_time(): + """Check if we should support optional start/end attributes for Audit. + + Version 1.1 of the API added support for start and end time of continuous + audits. + """ + return pecan.request.version.minor >= versions.MINOR_1_START_END_TIMING diff --git a/watcher/api/controllers/v1/versions.py b/watcher/api/controllers/v1/versions.py new file mode 100644 index 000000000..eec13f9fe --- /dev/null +++ b/watcher/api/controllers/v1/versions.py @@ -0,0 +1,52 @@ +# Copyright (c) 2015 Intel Corporation +# Copyright (c) 2018 SBCloud +# All Rights Reserved. +# +# 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. + + +# This is the version 1 API +BASE_VERSION = 1 + +# Here goes a short log of changes in every version. +# +# v1.0: corresponds to Rocky API +# v1.1: Add start/end time for continuous audit + +MINOR_0_ROCKY = 0 +MINOR_1_START_END_TIMING = 1 + +MINOR_MAX_VERSION = MINOR_1_START_END_TIMING + +# String representations of the minor and maximum versions +_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_0_ROCKY) +_MAX_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_MAX_VERSION) + + +def service_type_string(): + return 'infra-optim' + + +def min_version_string(): + """Returns the minimum supported API version (as a string)""" + return _MIN_VERSION_STRING + + +def max_version_string(): + """Returns the maximum supported API version (as a string). + + If the service is pinned, the maximum API version is the pinned + version. Otherwise, it is the maximum supported API version. + + """ + return _MAX_VERSION_STRING diff --git a/watcher/common/exception.py b/watcher/common/exception.py index 22de6bd32..c8eed8f3e 100644 --- a/watcher/common/exception.py +++ b/watcher/common/exception.py @@ -122,6 +122,11 @@ class NotAuthorized(WatcherException): code = 403 +class NotAcceptable(WatcherException): + msg_fmt = _("Request not acceptable.") + code = 406 + + class PolicyNotAuthorized(NotAuthorized): msg_fmt = _("Policy doesn't allow %(action)s to be performed.") diff --git a/watcher/tests/api/base.py b/watcher/tests/api/base.py index 8ce145b84..4bc0bd677 100644 --- a/watcher/tests/api/base.py +++ b/watcher/tests/api/base.py @@ -202,7 +202,8 @@ class FunctionalTest(base.DbTestCase): return response def get_json(self, path, expect_errors=False, headers=None, - extra_environ=None, q=[], path_prefix=PATH_PREFIX, **params): + extra_environ=None, q=[], path_prefix=PATH_PREFIX, + return_json=True, **params): """Sends simulated HTTP GET request to Pecan test app. :param path: url path of target service @@ -235,7 +236,7 @@ class FunctionalTest(base.DbTestCase): headers=headers, extra_environ=extra_environ, expect_errors=expect_errors) - if not expect_errors: + if return_json and not expect_errors: response = response.json print('GOT:%s' % response) return response diff --git a/watcher/tests/api/v1/test_audits.py b/watcher/tests/api/v1/test_audits.py index 9fff442f1..004709962 100644 --- a/watcher/tests/api/v1/test_audits.py +++ b/watcher/tests/api/v1/test_audits.py @@ -900,7 +900,10 @@ class TestPost(api_base.FunctionalTest): audit_dict['start_time'] = str(start_time) audit_dict['end_time'] = str(end_time) - response = self.post_json('/audits', audit_dict) + response = self.post_json( + '/audits', + audit_dict, + headers={'OpenStack-API-Version': 'infra-optim 1.1'}) self.assertEqual('application/json', response.content_type) self.assertEqual(201, response.status_int) self.assertEqual(objects.audit.State.PENDING, @@ -919,6 +922,34 @@ class TestPost(api_base.FunctionalTest): self.assertEqual(iso_start_time, return_start_time) self.assertEqual(iso_end_time, return_end_time) + @mock.patch.object(deapi.DecisionEngineAPI, 'trigger_audit') + def test_create_continuous_audit_with_start_end_time_incompatible_version( + self, mock_trigger_audit): + mock_trigger_audit.return_value = mock.ANY + start_time = datetime.datetime(2018, 3, 1, 0, 0) + end_time = datetime.datetime(2018, 4, 1, 0, 0) + + audit_dict = post_get_test_audit( + params_to_exclude=['uuid', 'state', 'scope', + 'next_run_time', 'hostname', 'goal'] + ) + audit_dict['audit_type'] = objects.audit.AuditType.CONTINUOUS.value + audit_dict['interval'] = '1200' + audit_dict['start_time'] = str(start_time) + audit_dict['end_time'] = str(end_time) + + response = self.post_json( + '/audits', + audit_dict, + headers={'X-OpenStack-Watcher-API-Version': '1.0'}, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(406, response.status_int) + expected_error_msg = 'Request not acceptable.' + self.assertTrue(response.json['error_message']) + self.assertIn(expected_error_msg, response.json['error_message']) + assert not mock_trigger_audit.called + class TestDelete(api_base.FunctionalTest): diff --git a/watcher/tests/api/v1/test_microversions.py b/watcher/tests/api/v1/test_microversions.py new file mode 100644 index 000000000..2dac17b72 --- /dev/null +++ b/watcher/tests/api/v1/test_microversions.py @@ -0,0 +1,104 @@ +# 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. + +from watcher.api.controllers.v1 import versions +from watcher.tests.api import base as api_base + + +SERVICE_TYPE = 'infra-optim' +H_MIN_VER = 'openstack-api-minimum-version' +H_MAX_VER = 'openstack-api-maximum-version' +H_RESP_VER = 'openstack-api-version' +MIN_VER = versions.min_version_string() +MAX_VER = versions.max_version_string() + + +class TestMicroversions(api_base.FunctionalTest): + + controller_list_response = [ + 'scoring_engines', 'audit_templates', 'audits', 'actions', + 'action_plans', 'services'] + + def setUp(self): + super(TestMicroversions, self).setUp() + + def test_wrong_major_version(self): + response = self.get_json( + '/', + headers={'OpenStack-API-Version': ' '.join([SERVICE_TYPE, + '10'])}, + expect_errors=True, return_json=False) + self.assertEqual('application/json', response.content_type) + self.assertEqual(406, response.status_int) + expected_error_msg = ('Invalid value for' + ' OpenStack-API-Version header') + self.assertTrue(response.json['error_message']) + self.assertIn(expected_error_msg, response.json['error_message']) + + def test_extend_initial_version_with_micro(self): + response = self.get_json( + '/', + headers={'OpenStack-API-Version': ' '.join([SERVICE_TYPE, + '1'])}, + return_json=False) + self.assertEqual(response.headers[H_MIN_VER], MIN_VER) + self.assertEqual(response.headers[H_MAX_VER], MAX_VER) + self.assertEqual(response.headers[H_RESP_VER], MIN_VER) + self.assertTrue(all(x in response.json.keys() for x in + self.controller_list_response)) + + def test_without_microversion(self): + response = self.get_json('/', return_json=False) + self.assertEqual(response.headers[H_MIN_VER], MIN_VER) + self.assertEqual(response.headers[H_MAX_VER], MAX_VER) + self.assertEqual(response.headers[H_RESP_VER], MIN_VER) + self.assertTrue(all(x in response.json.keys() for x in + self.controller_list_response)) + + def test_new_client_new_api(self): + response = self.get_json( + '/', + headers={'OpenStack-API-Version': ' '.join([SERVICE_TYPE, + '1.1'])}, + return_json=False) + self.assertEqual(response.headers[H_MIN_VER], MIN_VER) + self.assertEqual(response.headers[H_MAX_VER], MAX_VER) + self.assertEqual(response.headers[H_RESP_VER], '1.1') + self.assertTrue(all(x in response.json.keys() for x in + self.controller_list_response)) + + def test_latest_microversion(self): + response = self.get_json( + '/', + headers={'OpenStack-API-Version': ' '.join([SERVICE_TYPE, + 'latest'])}, + return_json=False) + self.assertEqual(response.headers[H_MIN_VER], MIN_VER) + self.assertEqual(response.headers[H_MAX_VER], MAX_VER) + self.assertEqual(response.headers[H_RESP_VER], MAX_VER) + self.assertTrue(all(x in response.json.keys() for x in + self.controller_list_response)) + + def test_unsupported_version(self): + response = self.get_json( + '/', + headers={'OpenStack-API-Version': ' '.join([SERVICE_TYPE, + '1.999'])}, + expect_errors=True) + self.assertEqual(406, response.status_int) + self.assertEqual(response.headers[H_MIN_VER], MIN_VER) + self.assertEqual(response.headers[H_MAX_VER], MAX_VER) + expected_error_msg = ('Version 1.999 was requested but the minor ' + 'version is not supported by this service. ' + 'The supported version range is') + self.assertTrue(response.json['error_message']) + self.assertIn(expected_error_msg, response.json['error_message'])