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:
Alfredo Moralejo
2025-08-06 17:37:15 +02:00
parent 6d35be11ec
commit e06f1b0475
30 changed files with 797 additions and 60 deletions

View File

@@ -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.

View File

@@ -0,0 +1,12 @@
[
{
"op": "replace",
"value": "SKIPPED",
"path": "/state"
},
{
"op": "replace",
"value": "Skipping due to maintenance window",
"path": "/status_message"
}
]

View File

@@ -0,0 +1,7 @@
[
{
"op": "replace",
"value": "SKIPPED",
"path": "/state"
}
]

View 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"
}

View File

@@ -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
}
]
}

View File

@@ -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
}

View File

@@ -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
}
]
}
}

View File

@@ -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
}

View File

@@ -51,5 +51,6 @@
"updated_at": null,
"hostname": null,
"start_time": null,
"end_time": null
"end_time": null,
"status_message": null
}

View File

@@ -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
}
]
}

View File

@@ -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
}

View File

@@ -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:**

View File

@@ -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

View File

@@ -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:**

View File

@@ -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

View File

@@ -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/

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -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.

View File

@@ -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.

View File

@@ -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)

View File

@@ -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 = []

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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'
}
]
)
]

View File

@@ -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):

View File

@@ -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,

View File

@@ -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)

View File

@@ -22,6 +22,7 @@ policy_data = """
"action:detail": "",
"action:get": "",
"action:get_all": "",
"action:update": "",
"action_plan:delete": "",
"action_plan:detail": "",