diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index 270a8dd26..c8cd2a3b7 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -189,6 +189,13 @@ action_state: in: body required: true type: string +action_status_message: + description: | + Message with additional information about the Action state. + in: body + required: false + type: string + min_version: 1.5 action_type: description: | Action type based on specific API action. Actions in Watcher are @@ -230,6 +237,13 @@ actionplan_state: in: body required: false type: string +actionplan_status_message: + description: | + Message with additional information about the Action Plan state. + in: body + required: false + type: string + min_version: 1.5 # Audit audit_autotrigger: @@ -320,6 +334,13 @@ audit_state: in: body required: true type: string +audit_status_message: + description: | + Message with additional information about the Audit state. + in: body + required: false + type: string + min_version: 1.5 audit_strategy: description: | The UUID or name of the Strategy. diff --git a/api-ref/source/samples/action-skip-request-with-message.json b/api-ref/source/samples/action-skip-request-with-message.json new file mode 100644 index 000000000..3bae1ed71 --- /dev/null +++ b/api-ref/source/samples/action-skip-request-with-message.json @@ -0,0 +1,12 @@ +[ + { + "op": "replace", + "value": "SKIPPED", + "path": "/state" + }, + { + "op": "replace", + "value": "Skipping due to maintenance window", + "path": "/status_message" + } +] \ No newline at end of file diff --git a/api-ref/source/samples/action-skip-request.json b/api-ref/source/samples/action-skip-request.json new file mode 100644 index 000000000..4aa51c2f4 --- /dev/null +++ b/api-ref/source/samples/action-skip-request.json @@ -0,0 +1,7 @@ +[ + { + "op": "replace", + "value": "SKIPPED", + "path": "/state" + } +] \ No newline at end of file diff --git a/api-ref/source/samples/action-skip-response.json b/api-ref/source/samples/action-skip-response.json new file mode 100644 index 000000000..153757146 --- /dev/null +++ b/api-ref/source/samples/action-skip-response.json @@ -0,0 +1,29 @@ +{ + "state": "SKIPPED", + "description": "Migrate instance to another compute node", + "parents": [ + "b4529294-1de6-4302-b57a-9b5d5dc363c6" + ], + "links": [ + { + "rel": "self", + "href": "http://controller:9322/v1/actions/54acc7a0-91b0-46ea-a5f7-4ae2b9df0b0a" + }, + { + "rel": "bookmark", + "href": "http://controller:9322/actions/54acc7a0-91b0-46ea-a5f7-4ae2b9df0b0a" + } + ], + "action_plan_uuid": "4cbc4ede-0d25-481b-b86e-998dbbd4f8bf", + "uuid": "54acc7a0-91b0-46ea-a5f7-4ae2b9df0b0a", + "deleted_at": null, + "updated_at": "2018-04-10T12:15:44.026973+00:00", + "input_parameters": { + "migration_type": "live", + "destination_node": "compute-2", + "resource_id": "a1b2c3d4-e5f6-7890-1234-567890abcdef" + }, + "action_type": "migrate", + "created_at": "2018-04-10T11:59:12.725147+00:00", + "status_message": "Action skipped by user. Reason:Skipping due to maintenance window" +} \ No newline at end of file diff --git a/api-ref/source/samples/actionplan-list-detailed-response.json b/api-ref/source/samples/actionplan-list-detailed-response.json index cf2dd4e0c..71b8bc87d 100644 --- a/api-ref/source/samples/actionplan-list-detailed-response.json +++ b/api-ref/source/samples/actionplan-list-detailed-response.json @@ -21,7 +21,8 @@ "uuid": "4cbc4ede-0d25-481b-b86e-998dbbd4f8bf", "audit_uuid": "7d100b05-0a86-491f-98a7-f93da19b272a", "created_at": "2018-04-10T11:59:52.640067+00:00", - "hostname": "controller" + "hostname": "controller", + "status_message": null } ] } diff --git a/api-ref/source/samples/actionplan-show-response.json b/api-ref/source/samples/actionplan-show-response.json index ce8af09fb..c649d9f8b 100644 --- a/api-ref/source/samples/actionplan-show-response.json +++ b/api-ref/source/samples/actionplan-show-response.json @@ -17,5 +17,6 @@ "strategy_name": "dummy_with_resize", "uuid": "4cbc4ede-0d25-481b-b86e-998dbbd4f8bf", "audit_uuid": "7d100b05-0a86-491f-98a7-f93da19b272a", - "hostname": "controller" -} \ No newline at end of file + "hostname": "controller", + "status_message": null +} diff --git a/api-ref/source/samples/actions-list-detailed-response.json b/api-ref/source/samples/actions-list-detailed-response.json index cd0d98677..5da125fa9 100644 --- a/api-ref/source/samples/actions-list-detailed-response.json +++ b/api-ref/source/samples/actions-list-detailed-response.json @@ -24,7 +24,8 @@ "duration": 3.2 }, "action_type": "sleep", - "created_at": "2018-03-26T11:56:08.235226+00:00" + "created_at": "2018-03-26T11:56:08.235226+00:00", + "status_message": null } ] -} \ No newline at end of file +} diff --git a/api-ref/source/samples/actions-show-response.json b/api-ref/source/samples/actions-show-response.json index b5959bd88..fb3f3e25b 100644 --- a/api-ref/source/samples/actions-show-response.json +++ b/api-ref/source/samples/actions-show-response.json @@ -22,5 +22,6 @@ "message": "Welcome" }, "action_type": "nop", - "created_at": "2018-04-10T11:59:12.725147+00:00" -} \ No newline at end of file + "created_at": "2018-04-10T11:59:12.725147+00:00", + "status_message": null +} diff --git a/api-ref/source/samples/audit-create-response.json b/api-ref/source/samples/audit-create-response.json index 3c11bc52d..f6cf89f49 100644 --- a/api-ref/source/samples/audit-create-response.json +++ b/api-ref/source/samples/audit-create-response.json @@ -51,5 +51,6 @@ "updated_at": null, "hostname": null, "start_time": null, - "end_time": null + "end_time": null, + "status_message": null } diff --git a/api-ref/source/samples/audit-list-detailed-response.json b/api-ref/source/samples/audit-list-detailed-response.json index a4a9240e1..0311e30c7 100644 --- a/api-ref/source/samples/audit-list-detailed-response.json +++ b/api-ref/source/samples/audit-list-detailed-response.json @@ -53,7 +53,8 @@ "updated_at": "2018-04-06T09:44:01.604146+00:00", "hostname": "controller", "start_time": null, - "end_time": null + "end_time": null, + "status_message": null } ] } diff --git a/api-ref/source/samples/audit-show-response.json b/api-ref/source/samples/audit-show-response.json index a4e7bf201..dd7a8a07b 100644 --- a/api-ref/source/samples/audit-show-response.json +++ b/api-ref/source/samples/audit-show-response.json @@ -51,5 +51,6 @@ "updated_at": "2018-04-06T11:54:01.266447+00:00", "hostname": "controller", "start_time": null, - "end_time": null + "end_time": null, + "status_message": null } diff --git a/api-ref/source/watcher-api-v1-actionplans.inc b/api-ref/source/watcher-api-v1-actionplans.inc index 7fa122019..99b39b73e 100644 --- a/api-ref/source/watcher-api-v1-actionplans.inc +++ b/api-ref/source/watcher-api-v1-actionplans.inc @@ -139,6 +139,7 @@ Response - global_efficacy: actionplan_global_efficacy - links: links - hostname: actionplan_hostname + - status_message: actionplan_status_message **Example JSON representation of an Action Plan:** @@ -177,6 +178,7 @@ Response - global_efficacy: actionplan_global_efficacy - links: links - hostname: actionplan_hostname + - status_message: actionplan_status_message **Example JSON representation of an Audit:** @@ -233,6 +235,7 @@ version 1: - global_efficacy: actionplan_global_efficacy - links: links - hostname: actionplan_hostname + - status_message: actionplan_status_message **Example JSON representation of an Action Plan:** diff --git a/api-ref/source/watcher-api-v1-actions.inc b/api-ref/source/watcher-api-v1-actions.inc index 837d4f79c..0968bb73f 100644 --- a/api-ref/source/watcher-api-v1-actions.inc +++ b/api-ref/source/watcher-api-v1-actions.inc @@ -114,6 +114,7 @@ Response - description: action_description - input_parameters: action_input_parameters - links: links + - status_message: action_status_message **Example JSON representation of an Action:** @@ -151,8 +152,62 @@ Response - description: action_description - input_parameters: action_input_parameters - links: links + - status_message: action_status_message **Example JSON representation of an Action:** .. literalinclude:: samples/actions-show-response.json :language: javascript + +Skip Action +=========== + +.. rest_method:: PATCH /v1/actions/{action_ident} + +Skips an Action resource by changing its state to SKIPPED. + +.. note:: + Only Actions in PENDING state can be skipped. The Action must belong to + an Action Plan in RECOMMENDED or PENDING state. This operation requires + API microversion 1.5 or later. + +Normal response codes: 200 + +Error codes: 400,404,403,409 + +Request +------- + +.. rest_parameters:: parameters.yaml + + - action_ident: action_ident + +**Example Action skip request:** + +.. literalinclude:: samples/action-skip-request.json + :language: javascript + +**Example Action skip request with custom status message:** + +.. literalinclude:: samples/action-skip-request-with-message.json + :language: javascript + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - uuid: uuid + - action_type: action_type + - state: action_state + - action_plan_uuid: action_action_plan_uuid + - parents: action_parents + - description: action_description + - input_parameters: action_input_parameters + - links: links + - status_message: action_status_message + +**Example JSON representation of a skipped Action:** + +.. literalinclude:: samples/action-skip-response.json + :language: javascript \ No newline at end of file diff --git a/api-ref/source/watcher-api-v1-audits.inc b/api-ref/source/watcher-api-v1-audits.inc index 6f738aacd..1e5c90a1a 100644 --- a/api-ref/source/watcher-api-v1-audits.inc +++ b/api-ref/source/watcher-api-v1-audits.inc @@ -85,6 +85,7 @@ version 1: - start_time: audit_starttime_resp - end_time: audit_endtime_resp - force: audit_force + - status_message: audit_status_message **Example JSON representation of an Audit:** @@ -184,6 +185,7 @@ Response - start_time: audit_starttime_resp - end_time: audit_endtime_resp - force: audit_force + - status_message: audit_status_message **Example JSON representation of an Audit:** @@ -231,6 +233,7 @@ Response - start_time: audit_starttime_resp - end_time: audit_endtime_resp - force: audit_force + - status_message: audit_status_message **Example JSON representation of an Audit:** @@ -286,6 +289,7 @@ version 1: - start_time: audit_starttime_resp - end_time: audit_endtime_resp - force: audit_force + - status_message: audit_status_message **Example JSON representation of an Audit:** @@ -341,6 +345,7 @@ Response - start_time: audit_starttime_resp - end_time: audit_endtime_resp - force: audit_force + - status_message: audit_status_message **Example JSON representation of an Audit:** diff --git a/devstack/lib/watcher b/devstack/lib/watcher index e3543b5a0..fe04d4c11 100644 --- a/devstack/lib/watcher +++ b/devstack/lib/watcher @@ -268,7 +268,7 @@ function configure_tempest_for_watcher { # Please make sure to update this when the microversion is updated, otherwise # new tests may be skipped. TEMPEST_WATCHER_MIN_MICROVERSION=${TEMPEST_WATCHER_MIN_MICROVERSION:-"1.0"} - TEMPEST_WATCHER_MAX_MICROVERSION=${TEMPEST_WATCHER_MAX_MICROVERSION:-"1.4"} + TEMPEST_WATCHER_MAX_MICROVERSION=${TEMPEST_WATCHER_MAX_MICROVERSION:-"1.5"} # Set microversion options in tempest.conf iniset $TEMPEST_CONFIG optimize min_microversion $TEMPEST_WATCHER_MIN_MICROVERSION diff --git a/doc/source/architecture.rst b/doc/source/architecture.rst index 16f567df7..67ecb601c 100644 --- a/doc/source/architecture.rst +++ b/doc/source/architecture.rst @@ -481,6 +481,39 @@ change to a new value: .. image:: ./images/action_plan_state_machine.png :width: 100% +.. _action_state_machine: + +Action State Machine +------------------------- + +An :ref:`Action ` has a life-cycle and its current state may +be one of the following: + +- **PENDING** : the :ref:`Action ` has not been executed + yet by the :ref:`Watcher Applier ` +- **SKIPPED** : the :ref:`Action ` will not be executed + because a predefined skipping condition is found by + :ref:`Watcher Applier ` or is explicitly + skipped by the :ref:`Administrator `. +- **ONGOING** : the :ref:`Action ` is currently being + processed by the :ref:`Watcher Applier ` +- **SUCCEEDED** : the :ref:`Action ` has been executed + successfully +- **FAILED** : an error occurred while trying to execute the + :ref:`Action ` +- **DELETED** : the :ref:`Action ` is still stored in the + :ref:`Watcher database ` but is not returned + any more through the Watcher APIs. +- **CANCELLED** : the :ref:`Action ` was in **PENDING** or + **ONGOING** state and was cancelled by the + :ref:`Administrator ` + +The following diagram shows the different possible states of an +:ref:`Action ` and what event makes the state change +change to a new value: + +.. image:: ./images/action_state_machine.png + :width: 100% .. _Watcher API: https://docs.openstack.org/api-ref/resource-optimization/ diff --git a/doc/source/image_src/plantuml/action_state_machine.txt b/doc/source/image_src/plantuml/action_state_machine.txt new file mode 100644 index 000000000..ce8c40aae --- /dev/null +++ b/doc/source/image_src/plantuml/action_state_machine.txt @@ -0,0 +1,23 @@ +@startuml + +skinparam ArrowColor DarkRed +skinparam StateBorderColor DarkRed +skinparam StateBackgroundColor LightYellow +skinparam Shadowing true + +[*] --> PENDING: The Watcher Planner\ncreates the Action +PENDING --> SKIPPED: The Action detects skipping condition\n in pre_condition or was\n skipped by cloud Admin. +PENDING --> FAILED: The Action fails unexpectedly\n in pre_condition. +PENDING --> ONGOING: The Watcher Applier starts executing/n the action. +ONGOING --> FAILED: Something failed while executing\nthe Action in the Watcher Applier +ONGOING --> SUCCEEDED: The Watcher Applier executed\nthe Action successfully +FAILED --> DELETED : Administrator removes\nAction Plan +SUCCEEDED --> DELETED : Administrator removes\n theAction +ONGOING --> CANCELLED : The Action was cancelled\n as part of an Action Plan cancellation. +PENDING --> CANCELLED : The Action was cancelled\n as part of an Action Plan cancellation. +CANCELLED --> DELETED +FAILED --> DELETED +SKIPPED --> DELETED +DELETED --> [*] + +@enduml diff --git a/doc/source/images/action_state_machine.png b/doc/source/images/action_state_machine.png new file mode 100644 index 000000000..6fb806f41 Binary files /dev/null and b/doc/source/images/action_state_machine.png differ diff --git a/releasenotes/notes/blueprint-add-skip-actions-4a5a997dc1133f13.yaml b/releasenotes/notes/blueprint-add-skip-actions-4a5a997dc1133f13.yaml new file mode 100644 index 000000000..041837590 --- /dev/null +++ b/releasenotes/notes/blueprint-add-skip-actions-4a5a997dc1133f13.yaml @@ -0,0 +1,20 @@ +--- +features: + - | + A new state ``SKIPPED`` has been added to the Actions. Actions can reach this state + in two situations: + + * Watcher detects a specific pre-defined condition in the `pre_condition` phase. + + * An admin sets the state to SKIPPED using a call to the new Patch API `/actions/{action_id}` + before the action plan is started. + + An action in ``SKIPPED`` state will not be executed by Watcher as part of an ActionPlan + run. + + Additionally, a new field ``status_message`` has been added to Audits, ActionPlans and + Actions which will be used to provide additional details about the state of an object. + + All these changes have been introduced in a new Watcher ``API microversion 1.5``. + + For additional information, see the API reference. diff --git a/watcher/api/controllers/rest_api_version_history.rst b/watcher/api/controllers/rest_api_version_history.rst index 1f67493d3..0ab78a9ac 100644 --- a/watcher/api/controllers/rest_api_version_history.rst +++ b/watcher/api/controllers/rest_api_version_history.rst @@ -39,3 +39,9 @@ Added list data model API. --- Added Watcher webhook API. It can be used to trigger audit with ``event`` type. + +1.5 +--- +Added support for SKIPPED actions status via PATCH support for Actions API. +This feature also introduces the ``status_message`` field to audits, actions +and action plans. diff --git a/watcher/api/controllers/v1/action.py b/watcher/api/controllers/v1/action.py index e5cf6e56a..fc8c2497d 100644 --- a/watcher/api/controllers/v1/action.py +++ b/watcher/api/controllers/v1/action.py @@ -62,9 +62,11 @@ are dynamically loaded by Watcher at launch time. from oslo_utils import timeutils import pecan from pecan import rest +import wsme from wsme import types as wtypes import wsmeext.pecan as wsme_pecan +from watcher._i18n import _ from watcher.api.controllers import base from watcher.api.controllers import link from watcher.api.controllers.v1 import collection @@ -82,14 +84,44 @@ def hide_fields_in_newer_versions(obj): These fields are only made available when the request's API version matches or exceeds the versions when these fields were introduced. """ - pass + if not api_utils.allow_skipped_action(): + obj.status_message = wtypes.Unset class ActionPatchType(types.JsonPatchType): + @staticmethod + def _validate_state(patch): + serialized_patch = {'path': patch.path, 'op': patch.op} + if patch.value is not wtypes.Unset: + serialized_patch['value'] = patch.value + + state_value = patch.value + if state_value and not hasattr(objects.action.State, state_value): + msg = _("Invalid state: %(state)s") + raise exception.PatchError( + patch=serialized_patch, reason=msg % dict(state=state_value)) + + @staticmethod + def validate(patch): + if patch.path == "/state": + ActionPatchType._validate_state(patch) + return types.JsonPatchType.validate(patch) + + # We only allow to patch state and status_message + @staticmethod + def allowed_attrs(): + return ["/state", "/status_message"] + + @staticmethod + def internal_attrs(): + return types.JsonPatchType.internal_attrs() + + # We do not allow to remove any attribute via PATCH so setting all fields + # as mandatory @staticmethod def mandatory_attrs(): - return [] + return ["/" + item for item in objects.Action.fields.keys()] class Action(base.APIBase): @@ -141,6 +173,9 @@ class Action(base.APIBase): links = wtypes.wsattr([link.Link], readonly=True) """A list containing a self link and associated action links""" + status_message = wtypes.text + """Status message""" + def __init__(self, **kwargs): super(Action, self).__init__() @@ -356,3 +391,85 @@ class ActionsController(rest.RestController): policy.enforce(context, 'action:get', action, action='action:get') return Action.convert_with_links(action) + + @wsme.validate(types.uuid, [ActionPatchType]) + @wsme_pecan.wsexpose(Action, types.uuid, body=[ActionPatchType]) + def patch(self, action_uuid, patch): + """Update an existing action. + + :param action_uuid: UUID of a action. + :param patch: a json PATCH document to apply to this action. + """ + if not api_utils.allow_skipped_action(): + raise exception.Invalid( + _("API microversion 1.5 or higher is required.")) + + context = pecan.request.context + action_to_update = api_utils.get_resource( + 'Action', action_uuid, eager=True) + policy.enforce(context, 'action:update', action_to_update, + action='action:update') + + try: + action_dict = action_to_update.as_dict() + action = Action(**api_utils.apply_jsonpatch(action_dict, patch)) + except api_utils.JSONPATCH_EXCEPTIONS as e: + raise exception.PatchError(patch=patch, reason=e) + + # Define allowed state transitions for actions + allowed_patch_transitions = [ + (objects.action.State.PENDING, objects.action.State.SKIPPED), + ] + + # Validate state transitions if state is being modified + if hasattr(action, 'state') and action.state != action_to_update.state: + transition = (action_to_update.state, action.state) + if transition not in allowed_patch_transitions: + error_message = _("State transition not allowed: " + "(%(initial_state)s -> %(new_state)s)") + raise exception.Conflict( + patch=patch, + message=error_message % dict( + initial_state=action_to_update.state, + new_state=action.state)) + action_plan = action_to_update.action_plan + if action_plan.state not in [objects.action_plan.State.RECOMMENDED, + objects.action_plan.State.PENDING]: + error_message = _("State update not allowed for actionplan " + "state: %(ap_state)s") + raise exception.Conflict( + patch=patch, + message=error_message % dict( + ap_state=action_plan.state)) + + status_message = _("Action skipped by user.") + # status_message update only allowed with status update + if (hasattr(action, 'status_message') and + action.status_message != action_to_update.status_message): + if action.state == action_to_update.state: + error_message = _( + "status_message update only allowed with state change") + raise exception.PatchError( + patch=patch, + reason=error_message) + else: + status_message = (_("%(status_message)s Reason: %(reason)s") + % dict(status_message=status_message, + reason=action.status_message)) + + action.status_message = status_message + + # Update only the fields that have changed + for field in objects.Action.fields: + try: + patch_val = getattr(action, field) + except AttributeError: + # Ignore fields that aren't exposed in the API + continue + if patch_val == wtypes.Unset: + patch_val = None + if action_to_update[field] != patch_val: + action_to_update[field] = patch_val + + action_to_update.save() + return Action.convert_with_links(action_to_update) diff --git a/watcher/api/controllers/v1/action_plan.py b/watcher/api/controllers/v1/action_plan.py index 8c77cac20..52c40d0d0 100644 --- a/watcher/api/controllers/v1/action_plan.py +++ b/watcher/api/controllers/v1/action_plan.py @@ -87,7 +87,8 @@ def hide_fields_in_newer_versions(obj): These fields are only made available when the request's API version matches or exceeds the versions when these fields were introduced. """ - pass + if not api_utils.allow_skipped_action(): + obj.status_message = wtypes.Unset class ActionPlanPatchType(types.JsonPatchType): @@ -112,7 +113,11 @@ class ActionPlanPatchType(types.JsonPatchType): @staticmethod def internal_attrs(): - return types.JsonPatchType.internal_attrs() + # There are global internal attributes and object specific ones. + # /status_message is only modified internally based on state changes + # and is not exposed in the patch API. + ap_internal_attrs = ['/status_message'] + return types.JsonPatchType.internal_attrs() + ap_internal_attrs @staticmethod def mandatory_attrs(): @@ -244,6 +249,9 @@ class ActionPlan(base.APIBase): hostname = wtypes.wsattr(wtypes.text, mandatory=False) """Hostname the actionplan is running on""" + status_message = wtypes.text + """Status message of the action plan""" + def __init__(self, **kwargs): super(ActionPlan, self).__init__() self.fields = [] diff --git a/watcher/api/controllers/v1/audit.py b/watcher/api/controllers/v1/audit.py index 4e59b17aa..1918f3937 100644 --- a/watcher/api/controllers/v1/audit.py +++ b/watcher/api/controllers/v1/audit.py @@ -77,6 +77,8 @@ def hide_fields_in_newer_versions(obj): obj.end_time = wtypes.Unset if not api_utils.allow_force(): obj.force = wtypes.Unset + if not api_utils.allow_skipped_action(): + obj.status_message = wtypes.Unset class AuditPostType(wtypes.Base): @@ -213,6 +215,14 @@ class AuditPatchType(types.JsonPatchType): def mandatory_attrs(): return ['/audit_template_uuid', '/type'] + @staticmethod + def internal_attrs(): + # There are global internal attributes and object specific ones. + # /status_message is only modified internally based on state changes + # and is not exposed in the patch API for Audits. + audit_internal_attrs = ['/status_message'] + return types.JsonPatchType.internal_attrs() + audit_internal_attrs + @staticmethod def validate(patch): @@ -375,6 +385,9 @@ class Audit(base.APIBase): """Allow Action Plan of this Audit be executed in parallel with other Action Plan""" + status_message = wtypes.wsattr(wtypes.text, mandatory=False) + """Status message of the audit""" + def __init__(self, **kwargs): self.fields = [] fields = list(objects.Audit.fields) diff --git a/watcher/api/controllers/v1/utils.py b/watcher/api/controllers/v1/utils.py index 25fb77e16..23cce2920 100644 --- a/watcher/api/controllers/v1/utils.py +++ b/watcher/api/controllers/v1/utils.py @@ -194,3 +194,12 @@ def allow_webhook_api(): """ return pecan.request.version.minor >= ( versions.VERSIONS.MINOR_4_WEBHOOK_API.value) + + +def allow_skipped_action(): + """Check if we should support skipped action. + + Version 1.5 of the API added support to skipped actions. + """ + return pecan.request.version.minor >= ( + versions.VERSIONS.MINOR_5_SKIPPED_ACTION.value) diff --git a/watcher/api/controllers/v1/versions.py b/watcher/api/controllers/v1/versions.py index 369c40064..f7dfbae77 100644 --- a/watcher/api/controllers/v1/versions.py +++ b/watcher/api/controllers/v1/versions.py @@ -23,7 +23,8 @@ class VERSIONS(enum.Enum): MINOR_2_FORCE = 2 # v1.2: Add force field to audit MINOR_3_DATAMODEL = 3 # v1.3: Add list datamodel API MINOR_4_WEBHOOK_API = 4 # v1.4: Add webhook trigger API - MINOR_MAX_VERSION = 4 + MINOR_5_SKIPPED_ACTION = 5 # v1.5: Add skipped action support + MINOR_MAX_VERSION = 5 # This is the version 1 API diff --git a/watcher/common/policies/action.py b/watcher/common/policies/action.py index 5e87d7c42..19f050ec9 100644 --- a/watcher/common/policies/action.py +++ b/watcher/common/policies/action.py @@ -49,6 +49,17 @@ rules = [ 'method': 'GET' } ] + ), + policy.DocumentedRuleDefault( + name=ACTION % 'update', + check_str=base.RULE_ADMIN_API, + description='Update an action.', + operations=[ + { + 'path': '/v1/actions/{action_id}', + 'method': 'PATCH' + } + ] ) ] diff --git a/watcher/tests/api/v1/test_actions.py b/watcher/tests/api/v1/test_actions.py index ab5cc6a74..2ae31f701 100644 --- a/watcher/tests/api/v1/test_actions.py +++ b/watcher/tests/api/v1/test_actions.py @@ -11,6 +11,7 @@ # limitations under the License. import itertools +from unittest import mock from http import HTTPStatus from oslo_config import cfg @@ -19,6 +20,7 @@ from wsme import types as wtypes from watcher.api.controllers.v1 import action as api_action from watcher.common import utils +from watcher.db import api as db_api from watcher import objects from watcher.tests.api import base as api_base from watcher.tests.api import utils as api_utils @@ -70,6 +72,36 @@ class TestListAction(api_base.FunctionalTest): response = self.get_json('/actions') self.assertEqual(action.uuid, response['actions'][0]["uuid"]) self._assert_action_fields(response['actions'][0]) + self.assertNotIn('status_message', response['actions'][0]) + + def test_one_with_status_message(self): + action = obj_utils.create_test_action( + self.context, parents=None, status_message='Fake message') + response = self.get_json( + '/actions', headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + self.assertEqual(action.uuid, response['actions'][0]["uuid"]) + self._assert_action_fields(response['actions'][0]) + # status_message is not in the basic actions list + self.assertNotIn('status_message', response['actions'][0]) + + def test_list_detail(self): + action = obj_utils.create_test_action( + self.context, status_message='Fake message', parents=None) + response = self.get_json('/actions/detail') + self.assertEqual(action.uuid, response['actions'][0]["uuid"]) + self._assert_action_fields(response['actions'][0]) + self.assertNotIn('status_message', response['actions'][0]) + + def test_list_detail_with_status_message(self): + action = obj_utils.create_test_action( + self.context, status_message='Fake message', parents=None) + response = self.get_json( + '/actions/detail', + headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + self.assertEqual(action.uuid, response['actions'][0]["uuid"]) + self._assert_action_fields(response['actions'][0]) + self.assertEqual( + 'Fake message', response['actions'][0]["status_message"]) def test_one_soft_deleted(self): action = obj_utils.create_test_action(self.context, parents=None) @@ -88,6 +120,42 @@ class TestListAction(api_base.FunctionalTest): self.assertEqual(action.uuid, response['uuid']) self.assertEqual(action.action_type, response['action_type']) self.assertEqual(action.input_parameters, response['input_parameters']) + self.assertNotIn('status_message', response) + self._assert_action_fields(response) + + def test_get_one_with_status_message(self): + action = obj_utils.create_test_action( + self.context, parents=None, status_message='test') + response = self.get_json( + '/actions/%s' % action['uuid'], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + self.assertEqual(action.uuid, response['uuid']) + self.assertEqual(action.action_type, response['action_type']) + self.assertEqual(action.input_parameters, response['input_parameters']) + self.assertEqual('test', response['status_message']) + self._assert_action_fields(response) + + def test_get_one_with_hidden_status_message(self): + action = obj_utils.create_test_action( + self.context, parents=None, status_message='test') + response = self.get_json( + '/actions/%s' % action['uuid'], + headers={'OpenStack-API-Version': 'infra-optim 1.4'}) + self.assertEqual(action.uuid, response['uuid']) + self.assertEqual(action.action_type, response['action_type']) + self.assertEqual(action.input_parameters, response['input_parameters']) + self.assertNotIn('status_message', response) + self._assert_action_fields(response) + + def test_get_one_with_empty_status_message(self): + action = obj_utils.create_test_action(self.context, parents=None) + response = self.get_json( + '/actions/%s' % action['uuid'], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + self.assertEqual(action.uuid, response['uuid']) + self.assertEqual(action.action_type, response['action_type']) + self.assertEqual(action.input_parameters, response['input_parameters']) + self.assertIsNone(response['status_message']) self._assert_action_fields(response) def test_get_one_soft_deleted(self): @@ -456,6 +524,166 @@ class TestListAction(api_base.FunctionalTest): self.assertEqual(3, len(response['actions'])) +class TestPatchAction(api_base.FunctionalTest): + + def setUp(self): + super(TestPatchAction, self).setUp() + obj_utils.create_test_goal(self.context) + obj_utils.create_test_strategy(self.context) + obj_utils.create_test_audit(self.context) + self.action_plan = obj_utils.create_test_action_plan( + self.context, + state=objects.action_plan.State.PENDING) + self.action = obj_utils.create_test_action(self.context, parents=None) + p = mock.patch.object(db_api.BaseConnection, 'update_action') + self.mock_action_update = p.start() + self.mock_action_update.side_effect = self._simulate_rpc_action_update + self.addCleanup(p.stop) + + def _simulate_rpc_action_update(self, action): + action.save() + return action + + def test_patch_action_not_allowed_old_microversion(self): + """Test that action patch is not allowed in older microversions""" + new_state = objects.action.State.SKIPPED + response = self.get_json('/actions/%s' % self.action.uuid) + self.assertNotEqual(new_state, response['state']) + + # Test with API version 1.4 (should fail) + response = self.patch_json( + '/actions/%s' % self.action.uuid, + [{'path': '/state', 'value': new_state, 'op': 'replace'}], + headers={'OpenStack-API-Version': 'infra-optim 1.4'}, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(HTTPStatus.BAD_REQUEST, response.status_int) + self.assertTrue(response.json['error_message']) + + def test_patch_action_allowed_new_microversion(self): + """Test that action patch is allowed in microversion 1.5+""" + new_state = objects.action.State.SKIPPED + response = self.get_json('/actions/%s' % self.action.uuid) + self.assertNotEqual(new_state, response['state']) + + # Test with API version 1.5 (should succeed) + response = self.patch_json( + '/actions/%s' % self.action.uuid, + [{'path': '/state', 'value': new_state, 'op': 'replace'}], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + self.assertEqual('application/json', response.content_type) + self.assertEqual(HTTPStatus.OK, response.status_int) + self.assertEqual(new_state, response.json['state']) + self.assertEqual('Action skipped by user.', + response.json['status_message']) + + def test_patch_action_invalid_state_transition(self): + """Test that invalid state transitions are rejected""" + # Try to transition from PENDING to SUCCEEDED (should fail) + new_state = objects.action.State.SUCCEEDED + response = self.patch_json( + '/actions/%s' % self.action.uuid, + [{'path': '/state', 'value': new_state, 'op': 'replace'}], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}, + expect_errors=True) + self.assertEqual(HTTPStatus.CONFLICT, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + self.assertIn("State transition not allowed: (PENDING -> SUCCEEDED)", + response.json['error_message']) + + def test_patch_action_skip_non_pending_ap(self): + """Test transition conditions on parent actionplan + + The PENDING to SKIPPED transition is not allowed if + the actionplan is not PENDING or RECOMMENDED state + """ + self.action_plan.state = objects.action_plan.State.ONGOING + self.action_plan.save() + new_state = objects.action.State.SKIPPED + response = self.patch_json( + '/actions/%s' % self.action.uuid, + [{'path': '/state', 'value': new_state, 'op': 'replace'}], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}, + expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(HTTPStatus.CONFLICT, response.status_int) + self.assertTrue(response.json['error_message']) + self.assertIn("State update not allowed for actionplan state: ONGOING", + response.json['error_message']) + + def test_patch_action_skip_transition_with_status_message(self): + """Test that PENDING to SKIPPED transition is allowed""" + new_state = objects.action.State.SKIPPED + response = self.patch_json( + '/actions/%s' % self.action.uuid, + [{'path': '/state', 'value': new_state, 'op': 'replace'}, + {'path': '/status_message', 'value': 'test message', + 'op': 'replace'}], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + self.assertEqual('application/json', response.content_type) + self.assertEqual(HTTPStatus.OK, response.status_int) + self.assertEqual(new_state, response.json['state']) + self.assertEqual( + 'Action skipped by user. Reason: test message', + response.json['status_message']) + + def test_patch_action_invalid_state_value(self): + """Test that invalid state values are rejected""" + invalid_state = "INVALID_STATE" + response = self.patch_json( + '/actions/%s' % self.action.uuid, + [{'path': '/state', 'value': invalid_state, 'op': 'replace'}], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}, + expect_errors=True) + self.assertEqual(HTTPStatus.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + + def test_patch_action_remove_status_message_not_allowed(self): + """Test that remove fields is not allowed""" + response = self.patch_json( + '/actions/%s' % self.action.uuid, + [{'path': '/status_message', 'op': 'remove'}], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}, + expect_errors=True) + self.assertEqual(HTTPStatus.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + self.assertIn("is a mandatory attribute and can not be removed", + response.json['error_message']) + + def test_patch_action_status_message_not_allowed(self): + """Test that status_message cannot be patched directly""" + response = self.patch_json( + '/actions/%s' % self.action.uuid, + [{'path': '/status_message', 'value': 'test message', + 'op': 'replace'}], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}, + expect_errors=True) + self.assertEqual(HTTPStatus.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertIn("status_message update only allowed with state change", + response.json['error_message']) + self.assertIsNone(self.action.status_message) + + def test_patch_action_one_allowed_one_not_allowed(self): + """Test that status_message cannot be patched directly""" + new_state = objects.action.State.SKIPPED + response = self.patch_json( + '/actions/%s' % self.action.uuid, + [{'path': '/state', 'value': new_state, 'op': 'replace'}, + {'path': '/action_plan_id', 'value': 56, 'op': 'replace'}], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}, + expect_errors=True) + self.assertEqual(HTTPStatus.BAD_REQUEST, response.status_int) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + self.assertIn("\'/action_plan_id\' is not an allowed attribute and " + "can not be updated", response.json['error_message']) + self.assertIsNone(self.action.status_message) + + class TestActionPolicyEnforcement(api_base.FunctionalTest): def setUp(self): @@ -495,6 +723,16 @@ class TestActionPolicyEnforcement(api_base.FunctionalTest): '/actions/detail', expect_errors=True) + def test_policy_disallow_patch(self): + action = obj_utils.create_test_action(self.context) + self._common_policy_check( + "action:update", self.patch_json, + '/actions/%s' % action.uuid, + [{'path': '/state', 'value': objects.action.State.SKIPPED, + 'op': 'replace'}], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}, + expect_errors=True) + class TestActionPolicyEnforcementWithAdminContext(TestListAction, api_base.AdminRoleTest): diff --git a/watcher/tests/api/v1/test_actions_plans.py b/watcher/tests/api/v1/test_actions_plans.py index 7058b3724..e428750d6 100644 --- a/watcher/tests/api/v1/test_actions_plans.py +++ b/watcher/tests/api/v1/test_actions_plans.py @@ -51,6 +51,17 @@ class TestListActionPlan(api_base.FunctionalTest): self.assertEqual(action_plan.uuid, response['action_plans'][0]["uuid"]) self._assert_action_plans_fields(response['action_plans'][0]) + self.assertNotIn('status_message', response['action_plans'][0]) + + def test_one_with_status_message(self): + action_plan = obj_utils.create_test_action_plan( + self.context, headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + response = self.get_json('/action_plans') + self.assertEqual(action_plan.uuid, + response['action_plans'][0]["uuid"]) + self._assert_action_plans_fields(response['action_plans'][0]) + # status_message is not in the basic action_plans list + self.assertNotIn('status_message', response['action_plans'][0]) def test_one_soft_deleted(self): action_plan = obj_utils.create_test_action_plan(self.context) @@ -78,6 +89,29 @@ class TestListActionPlan(api_base.FunctionalTest): 'unit': '%'}], response['efficacy_indicators']) + def test_get_one_ok_with_status_message(self): + action_plan = obj_utils.create_test_action_plan( + self.context, status_message='Fake message') + obj_utils.create_test_efficacy_indicator( + self.context, action_plan_id=action_plan['id']) + response = self.get_json( + '/action_plans/%s' % action_plan['uuid'], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + self.assertEqual(action_plan.uuid, response['uuid']) + self._assert_action_plans_fields(response) + self.assertEqual("Fake message", response['status_message']) + + def test_get_one_ok_with_empty_status_message(self): + action_plan = obj_utils.create_test_action_plan(self.context) + obj_utils.create_test_efficacy_indicator( + self.context, action_plan_id=action_plan['id']) + response = self.get_json( + '/action_plans/%s' % action_plan['uuid'], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + self.assertEqual(action_plan.uuid, response['uuid']) + self._assert_action_plans_fields(response) + self.assertIsNone(response['status_message']) + def test_get_one_soft_deleted(self): action_plan = obj_utils.create_test_action_plan(self.context) action_plan.soft_delete() @@ -96,6 +130,30 @@ class TestListActionPlan(api_base.FunctionalTest): self.assertEqual(action_plan.uuid, response['action_plans'][0]["uuid"]) self._assert_action_plans_fields(response['action_plans'][0]) + self.assertNotIn('status_message', response['action_plans'][0]) + + def test_detail_with_status_message(self): + action_plan = obj_utils.create_test_action_plan( + self.context, status_message='Fake message') + response = self.get_json( + '/action_plans/detail', + headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + self.assertEqual(action_plan.uuid, + response['action_plans'][0]["uuid"]) + self._assert_action_plans_fields(response['action_plans'][0]) + self.assertEqual( + "Fake message", response['action_plans'][0]['status_message']) + + def test_detail_with_hidden_status_message(self): + action_plan = obj_utils.create_test_action_plan( + self.context, status_message='Fake message') + response = self.get_json( + '/action_plans/detail', + headers={'OpenStack-API-Version': 'infra-optim 1.4'}) + self.assertEqual(action_plan.uuid, + response['action_plans'][0]["uuid"]) + self._assert_action_plans_fields(response['action_plans'][0]) + self.assertNotIn('status_message', response['action_plans'][0]) def test_detail_soft_deleted(self): action_plan = obj_utils.create_test_action_plan(self.context) @@ -518,6 +576,24 @@ class TestPatch(api_base.FunctionalTest): applier_mock.assert_called_once_with(mock.ANY, self.action_plan.uuid) + def test_replace_status_message_denied(self): + response = self.patch_json( + '/action_plans/%s' % self.action_plan.uuid, + [{'path': '/status_message', 'value': 'test', 'op': 'replace'}], + expect_errors=True) + self.assertEqual(HTTPStatus.BAD_REQUEST, response.status_code) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + + def test_add_status_message_denied(self): + response = self.patch_json( + '/action_plans/%s' % self.action_plan.uuid, + [{'path': '/status_message', 'value': 'test', 'op': 'add'}], + expect_errors=True) + self.assertEqual(HTTPStatus.BAD_REQUEST, response.status_code) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + ALLOWED_TRANSITIONS = [ {"original_state": objects.action_plan.State.RECOMMENDED, diff --git a/watcher/tests/api/v1/test_audits.py b/watcher/tests/api/v1/test_audits.py index 1a2e7b859..c754c4918 100644 --- a/watcher/tests/api/v1/test_audits.py +++ b/watcher/tests/api/v1/test_audits.py @@ -59,7 +59,7 @@ def post_get_test_audit_with_predefined_strategy(**kw): audit = api_utils.audit_post_data(**kw) audit_template = db_utils.get_test_audit_template( strategy_id=strategy['id']) - del_keys = ['goal_id', 'strategy_id', 'status_message'] + del_keys = ['goal_id', 'strategy_id'] add_keys = {'audit_template_uuid': audit_template['uuid'], } for k in del_keys: @@ -104,6 +104,16 @@ class TestListAudit(api_base.FunctionalTest): self.assertEqual(audit.uuid, response['audits'][0]["uuid"]) self._assert_audit_fields(response['audits'][0]) + def test_list_with_status_message(self): + audit = obj_utils.create_test_audit( + self.context, status_message='Fake message') + response = self.get_json( + '/audits', headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + self.assertEqual(audit.uuid, response['audits'][0]["uuid"]) + self._assert_audit_fields(response['audits'][0]) + # status_message is not in the basic actions list + self.assertNotIn('status_message', response['audits'][0]) + def test_one_soft_deleted(self): audit = obj_utils.create_test_audit(self.context) audit.soft_delete() @@ -120,6 +130,36 @@ class TestListAudit(api_base.FunctionalTest): response = self.get_json('/audits/%s' % audit['uuid']) self.assertEqual(audit.uuid, response['uuid']) self._assert_audit_fields(response) + self.assertNotIn('status_message', response) + + def test_get_one_with_status_message(self): + audit = obj_utils.create_test_audit( + self.context, status_message='Fake message') + response = self.get_json( + '/audits/%s' % audit['uuid'], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + self.assertEqual(audit.uuid, response['uuid']) + self._assert_audit_fields(response) + self.assertEqual('Fake message', response['status_message']) + + def test_get_one_with_hidden_status_message(self): + audit = obj_utils.create_test_audit( + self.context, status_message='Fake message') + response = self.get_json( + '/audits/%s' % audit['uuid'], + headers={'OpenStack-API-Version': 'infra-optim 1.4'}) + self.assertEqual(audit.uuid, response['uuid']) + self._assert_audit_fields(response) + self.assertNotIn('status_message', response) + + def test_get_one_with_empty_status_message(self): + audit = obj_utils.create_test_audit( + self.context) + response = self.get_json( + '/audits/%s' % audit['uuid'], + headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + self.assertEqual(audit.uuid, response['uuid']) + self.assertIsNone(response['status_message']) def test_get_one_soft_deleted(self): audit = obj_utils.create_test_audit(self.context) @@ -138,6 +178,18 @@ class TestListAudit(api_base.FunctionalTest): response = self.get_json('/audits/detail') self.assertEqual(audit.uuid, response['audits'][0]["uuid"]) self._assert_audit_fields(response['audits'][0]) + self.assertNotIn('status_message', response['audits'][0]) + + def test_detail_with_status_message(self): + audit = obj_utils.create_test_audit( + self.context, status_message='Fake message') + response = self.get_json( + '/audits/detail', + headers={'OpenStack-API-Version': 'infra-optim 1.5'}) + self.assertEqual(audit.uuid, response['audits'][0]["uuid"]) + self._assert_audit_fields(response['audits'][0]) + self.assertEqual( + 'Fake message', response['audits'][0]['status_message']) def test_detail_soft_deleted(self): audit = obj_utils.create_test_audit(self.context) @@ -314,6 +366,15 @@ class TestPatch(api_base.FunctionalTest): self.assertEqual('application/json', response.content_type) self.assertTrue(response.json['error_message']) + def test_replace_status_message_denied(self): + response = self.patch_json( + '/audits/%s' % utils.generate_uuid(), + [{'path': '/status_message', 'value': 'test', 'op': 'replace'}], + expect_errors=True) + self.assertEqual(HTTPStatus.BAD_REQUEST, response.status_code) + self.assertEqual('application/json', response.content_type) + self.assertTrue(response.json['error_message']) + def test_add_ok(self): new_state = objects.audit.State.SUCCEEDED response = self.patch_json( @@ -500,8 +561,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( state=objects.audit.State.PENDING, params_to_exclude=['uuid', 'state', 'interval', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) response = self.post_json('/audits', audit_dict) self.assertEqual('application/json', response.content_type) @@ -542,7 +602,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( state=objects.audit.State.PENDING, params_to_exclude=['uuid', 'state', 'interval', 'scope', - 'next_run_time', 'hostname', 'status_message']) + 'next_run_time', 'hostname']) response = self.post_json('/audits', audit_dict, expect_errors=True) self.assertEqual(HTTPStatus.BAD_REQUEST, response.status_int) @@ -556,7 +616,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'interval', 'scope', 'next_run_time', 'hostname', - 'audit_template_uuid', 'status_message']) + 'audit_template_uuid']) response = self.post_json('/audits', audit_dict) self.assertEqual('application/json', response.content_type) @@ -572,8 +632,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'interval', 'scope', 'next_run_time', 'hostname', - 'audit_template_uuid', 'strategy', - 'status_message']) + 'audit_template_uuid', 'strategy']) response = self.post_json('/audits', audit_dict) self.assertEqual('application/json', response.content_type) @@ -589,7 +648,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'interval', 'scope', 'next_run_time', 'hostname', - 'audit_template_uuid', 'status_message'], + 'audit_template_uuid'], use_named_goal=True) response = self.post_json('/audits', audit_dict) @@ -606,8 +665,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'interval', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) # Make the audit template UUID some garbage value audit_dict['audit_template_uuid'] = ( '01234567-8910-1112-1314-151617181920') @@ -627,8 +685,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( state=objects.audit.State.PENDING, params_to_exclude=['uuid', 'interval', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) state = audit_dict['state'] del audit_dict['state'] with mock.patch.object(self.dbapi, 'create_audit', @@ -645,8 +702,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'interval', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) response = self.post_json('/audits', audit_dict) self.assertEqual('application/json', response.content_type) @@ -661,8 +717,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) audit_dict['audit_type'] = objects.audit.AuditType.CONTINUOUS.value audit_dict['interval'] = '1200' @@ -681,8 +736,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) audit_dict['audit_type'] = objects.audit.AuditType.CONTINUOUS.value audit_dict['interval'] = '* * * * *' @@ -701,8 +755,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) audit_dict['audit_type'] = objects.audit.AuditType.CONTINUOUS.value audit_dict['interval'] = 'zxc' @@ -722,8 +775,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'interval', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) audit_dict['audit_type'] = objects.audit.AuditType.CONTINUOUS.value response = self.post_json('/audits', audit_dict, expect_errors=True) @@ -740,8 +792,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) audit_dict['audit_type'] = objects.audit.AuditType.ONESHOT.value response = self.post_json('/audits', audit_dict, expect_errors=True) @@ -757,8 +808,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( state=objects.audit.State.PENDING, params_to_exclude=['uuid', 'state', 'interval', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) response = self.post_json('/audits', audit_dict) de_mock.assert_called_once_with(mock.ANY, response.json['uuid']) @@ -780,8 +830,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( parameters={'name': 'Tom'}, params_to_exclude=['uuid', 'state', 'interval', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) response = self.post_json('/audits', audit_dict, expect_errors=True) self.assertEqual('application/json', response.content_type) @@ -827,7 +876,7 @@ class TestPost(api_base.FunctionalTest): audit_dict['audit_template_uuid'] = audit_template['uuid'] del_keys = ['uuid', 'goal_id', 'strategy_id', 'state', 'interval', - 'scope', 'next_run_time', 'hostname', 'status_message'] + 'scope', 'next_run_time', 'hostname'] for k in del_keys: del audit_dict[k] @@ -850,7 +899,7 @@ class TestPost(api_base.FunctionalTest): audit_dict['audit_template_uuid'] = audit_template['uuid'] del_keys = ['uuid', 'goal_id', 'strategy_id', 'state', 'interval', - 'scope', 'next_run_time', 'hostname', 'status_message'] + 'scope', 'next_run_time', 'hostname'] for k in del_keys: del audit_dict[k] @@ -906,8 +955,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['state', 'interval', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) normal_name = 'this audit name is just for test' # long_name length exceeds 63 characters long_name = normal_name + audit_dict['uuid'] @@ -934,8 +982,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message'] + 'next_run_time', 'hostname', 'goal'] ) audit_dict['audit_type'] = objects.audit.AuditType.CONTINUOUS.value audit_dict['interval'] = '1200' @@ -971,8 +1018,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message'] + 'next_run_time', 'hostname', 'goal'] ) audit_dict['audit_type'] = objects.audit.AuditType.CONTINUOUS.value audit_dict['interval'] = '1200' @@ -997,8 +1043,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'interval', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) response = self.post_json( '/audits', @@ -1014,8 +1059,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'interval', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) audit_dict['force'] = True response = self.post_json( @@ -1033,8 +1077,7 @@ class TestPost(api_base.FunctionalTest): audit_dict = post_get_test_audit( params_to_exclude=['uuid', 'state', 'interval', 'scope', 'next_run_time', 'hostname', 'goal', - 'audit_template_uuid', 'name', - 'status_message']) + 'audit_template_uuid', 'name']) response = self.post_json( '/audits', @@ -1159,8 +1202,7 @@ class TestAuditPolicyEnforcement(api_base.FunctionalTest): audit_dict = post_get_test_audit( state=objects.audit.State.PENDING, params_to_exclude=['uuid', 'state', 'scope', - 'next_run_time', 'hostname', 'goal', - 'status_message']) + 'next_run_time', 'hostname', 'goal']) self._common_policy_check( "audit:create", self.post_json, '/audits', audit_dict, expect_errors=True) diff --git a/watcher/tests/fake_policy.py b/watcher/tests/fake_policy.py index 4d493e096..f809fa229 100644 --- a/watcher/tests/fake_policy.py +++ b/watcher/tests/fake_policy.py @@ -22,6 +22,7 @@ policy_data = """ "action:detail": "", "action:get": "", "action:get_all": "", + "action:update": "", "action_plan:delete": "", "action_plan:detail": "",