API changes for skipped actions: patch actions and status_message
This patch implements the changes in the API required for the skipped action blueprint. It includes: - New field `status_message` is visible in API get calls for Audits, ActionPlans and Audits. - New Patch call is added to `/actions/{action_id}` which allows to manually move actions in PENDING state to SKIPPED for ActionPlans which have not been started. - A new API microversion 1.5 is added for these changes. It also adds requried tests and documentation. Implements: blueprint add-skip-actions Assisted-By: Cursor (claude-4-sonnet) Change-Id: I71fb9af76085e5941a7fd3e9e4c89d6f3a3ada47 Signed-off-by: Alfredo Moralejo <amoralej@redhat.com>
This commit is contained in:
@@ -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.
|
||||
|
12
api-ref/source/samples/action-skip-request-with-message.json
Normal file
12
api-ref/source/samples/action-skip-request-with-message.json
Normal file
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"op": "replace",
|
||||
"value": "SKIPPED",
|
||||
"path": "/state"
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"value": "Skipping due to maintenance window",
|
||||
"path": "/status_message"
|
||||
}
|
||||
]
|
7
api-ref/source/samples/action-skip-request.json
Normal file
7
api-ref/source/samples/action-skip-request.json
Normal file
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"op": "replace",
|
||||
"value": "SKIPPED",
|
||||
"path": "/state"
|
||||
}
|
||||
]
|
29
api-ref/source/samples/action-skip-response.json
Normal file
29
api-ref/source/samples/action-skip-response.json
Normal file
@@ -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"
|
||||
}
|
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@@ -17,5 +17,6 @@
|
||||
"strategy_name": "dummy_with_resize",
|
||||
"uuid": "4cbc4ede-0d25-481b-b86e-998dbbd4f8bf",
|
||||
"audit_uuid": "7d100b05-0a86-491f-98a7-f93da19b272a",
|
||||
"hostname": "controller"
|
||||
}
|
||||
"hostname": "controller",
|
||||
"status_message": null
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@@ -22,5 +22,6 @@
|
||||
"message": "Welcome"
|
||||
},
|
||||
"action_type": "nop",
|
||||
"created_at": "2018-04-10T11:59:12.725147+00:00"
|
||||
}
|
||||
"created_at": "2018-04-10T11:59:12.725147+00:00",
|
||||
"status_message": null
|
||||
}
|
||||
|
@@ -51,5 +51,6 @@
|
||||
"updated_at": null,
|
||||
"hostname": null,
|
||||
"start_time": null,
|
||||
"end_time": null
|
||||
"end_time": null,
|
||||
"status_message": null
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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:**
|
||||
|
||||
|
@@ -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
|
@@ -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:**
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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 <action_definition>` has a life-cycle and its current state may
|
||||
be one of the following:
|
||||
|
||||
- **PENDING** : the :ref:`Action <action_definition>` has not been executed
|
||||
yet by the :ref:`Watcher Applier <watcher_applier_definition>`
|
||||
- **SKIPPED** : the :ref:`Action <action_definition>` will not be executed
|
||||
because a predefined skipping condition is found by
|
||||
:ref:`Watcher Applier <watcher_applier_definition>` or is explicitly
|
||||
skipped by the :ref:`Administrator <administrator_definition>`.
|
||||
- **ONGOING** : the :ref:`Action <action_definition>` is currently being
|
||||
processed by the :ref:`Watcher Applier <watcher_applier_definition>`
|
||||
- **SUCCEEDED** : the :ref:`Action <action_definition>` has been executed
|
||||
successfully
|
||||
- **FAILED** : an error occurred while trying to execute the
|
||||
:ref:`Action <action_definition>`
|
||||
- **DELETED** : the :ref:`Action <action_definition>` is still stored in the
|
||||
:ref:`Watcher database <watcher_database_definition>` but is not returned
|
||||
any more through the Watcher APIs.
|
||||
- **CANCELLED** : the :ref:`Action <action_definition>` was in **PENDING** or
|
||||
**ONGOING** state and was cancelled by the
|
||||
:ref:`Administrator <administrator_definition>`
|
||||
|
||||
The following diagram shows the different possible states of an
|
||||
:ref:`Action <action_definition>` 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/
|
||||
|
23
doc/source/image_src/plantuml/action_state_machine.txt
Normal file
23
doc/source/image_src/plantuml/action_state_machine.txt
Normal file
@@ -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
|
BIN
doc/source/images/action_state_machine.png
Normal file
BIN
doc/source/images/action_state_machine.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 75 KiB |
@@ -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.
|
@@ -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.
|
||||
|
@@ -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)
|
||||
|
@@ -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 = []
|
||||
|
@@ -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)
|
||||
|
@@ -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)
|
||||
|
@@ -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
|
||||
|
@@ -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'
|
||||
}
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
|
@@ -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):
|
||||
|
@@ -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,
|
||||
|
@@ -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)
|
||||
|
@@ -22,6 +22,7 @@ policy_data = """
|
||||
"action:detail": "",
|
||||
"action:get": "",
|
||||
"action:get_all": "",
|
||||
"action:update": "",
|
||||
|
||||
"action_plan:delete": "",
|
||||
"action_plan:detail": "",
|
||||
|
Reference in New Issue
Block a user