Merge "Add action update command to support skipping actions manually"
This commit is contained in:
@@ -24,7 +24,7 @@ The watcher client is the command-line interface (CLI) for the
|
|||||||
Infrastructure Optimization service (watcher) API
|
Infrastructure Optimization service (watcher) API
|
||||||
and its extensions.
|
and its extensions.
|
||||||
|
|
||||||
This chapter documents :command:`watcher` version ``1.3.0``.
|
This chapter documents watcherclient version ``4.9.0``.
|
||||||
|
|
||||||
For help on a specific :command:`watcher` command, enter:
|
For help on a specific :command:`watcher` command, enter:
|
||||||
|
|
||||||
@@ -214,6 +214,37 @@ Show detailed information about a given action.
|
|||||||
``-h, --help``
|
``-h, --help``
|
||||||
show this help message and exit
|
show this help message and exit
|
||||||
|
|
||||||
|
.. _watcher_action_update:
|
||||||
|
|
||||||
|
watcher action update
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
.. code-block:: console
|
||||||
|
|
||||||
|
usage: watcher action update [-h] [-f {html,json,shell,table,value,yaml}]
|
||||||
|
[-c COLUMN] [--max-width <integer>] [--fit-width]
|
||||||
|
[--print-empty] [--noindent] [--prefix PREFIX]
|
||||||
|
[--state <state>] [--reason <reason>] <action>
|
||||||
|
|
||||||
|
Update action command.
|
||||||
|
|
||||||
|
**Positional arguments:**
|
||||||
|
|
||||||
|
``<action>``
|
||||||
|
UUID of the action
|
||||||
|
|
||||||
|
**Optional arguments:**
|
||||||
|
|
||||||
|
``-h, --help``
|
||||||
|
show this help message and exit
|
||||||
|
|
||||||
|
``--state <state>``
|
||||||
|
New state for the action (e.g., SKIPPED)
|
||||||
|
|
||||||
|
``--reason <reason>``
|
||||||
|
Reason for the action state change.
|
||||||
|
|
||||||
|
|
||||||
.. _watcher_actionplan_cancel:
|
.. _watcher_actionplan_cancel:
|
||||||
|
|
||||||
watcher actionplan cancel
|
watcher actionplan cancel
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
features:
|
||||||
|
- |
|
||||||
|
Added support for updating action state through the new
|
||||||
|
``openstack optimize action update`` command. This feature allows
|
||||||
|
operators to manually change action states. The command
|
||||||
|
supports the following options:
|
||||||
|
|
||||||
|
* ``--state <state>`` - New state for the action (required)
|
||||||
|
* ``--reason <reason>`` - Optional reason for the state change
|
||||||
|
|
||||||
|
Currently, the only use case for this update is to Skip an action
|
||||||
|
before starting an Action Plan with an optional reason by setting
|
||||||
|
the state to SKIPPED:
|
||||||
|
|
||||||
|
$ openstack optimize action update --state SKIPPED --reason "Manual skip" <action-uuid>
|
||||||
|
|
||||||
|
This feature requires Watcher API microversion 1.5 or higher.
|
||||||
|
|
||||||
|
upgrade:
|
||||||
|
- |
|
||||||
|
The maximum supported API version has been increased from 1.1 to 1.5
|
||||||
|
to support the new action update functionality. This change maintains
|
||||||
|
full backward compatibility with existing deployments.
|
||||||
@@ -60,6 +60,7 @@ openstack.infra_optim.v1 =
|
|||||||
|
|
||||||
optimize_action_show = watcherclient.v1.action_shell:ShowAction
|
optimize_action_show = watcherclient.v1.action_shell:ShowAction
|
||||||
optimize_action_list = watcherclient.v1.action_shell:ListAction
|
optimize_action_list = watcherclient.v1.action_shell:ListAction
|
||||||
|
optimize_action_update = watcherclient.v1.action_shell:UpdateAction
|
||||||
|
|
||||||
optimize_scoringengine_show = watcherclient.v1.scoring_engine_shell:ShowScoringEngine
|
optimize_scoringengine_show = watcherclient.v1.scoring_engine_shell:ShowScoringEngine
|
||||||
optimize_scoringengine_list = watcherclient.v1.scoring_engine_shell:ListScoringEngine
|
optimize_scoringengine_list = watcherclient.v1.scoring_engine_shell:ListScoringEngine
|
||||||
@@ -99,6 +100,7 @@ watcherclient.v1 =
|
|||||||
|
|
||||||
action_show = watcherclient.v1.action_shell:ShowAction
|
action_show = watcherclient.v1.action_shell:ShowAction
|
||||||
action_list = watcherclient.v1.action_shell:ListAction
|
action_list = watcherclient.v1.action_shell:ListAction
|
||||||
|
action_update = watcherclient.v1.action_shell:UpdateAction
|
||||||
|
|
||||||
scoringengine_show = watcherclient.v1.scoring_engine_shell:ShowScoringEngine
|
scoringengine_show = watcherclient.v1.scoring_engine_shell:ShowScoringEngine
|
||||||
scoringengine_list = watcherclient.v1.scoring_engine_shell:ListScoringEngine
|
scoringengine_list = watcherclient.v1.scoring_engine_shell:ListScoringEngine
|
||||||
|
|||||||
@@ -32,4 +32,4 @@ API_MIN_VERSION = api_versioning.APIVersion("1.0")
|
|||||||
# when client supported the max version, and bumped sequentially, otherwise
|
# when client supported the max version, and bumped sequentially, otherwise
|
||||||
# the client may break due to server side new version may include some
|
# the client may break due to server side new version may include some
|
||||||
# backward incompatible change.
|
# backward incompatible change.
|
||||||
API_MAX_VERSION = api_versioning.APIVersion("1.1")
|
API_MAX_VERSION = api_versioning.APIVersion("1.5")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ if not LOG.handlers:
|
|||||||
|
|
||||||
MINOR_1_START_END_TIMING = '1.1'
|
MINOR_1_START_END_TIMING = '1.1'
|
||||||
MINOR_2_FORCE_AUDIT = '1.2'
|
MINOR_2_FORCE_AUDIT = '1.2'
|
||||||
|
MINOR_5_ACTION_UPDATE = '1.5'
|
||||||
HEADER_NAME = "OpenStack-API-Version"
|
HEADER_NAME = "OpenStack-API-Version"
|
||||||
# key is a deprecated version and value is an alternative version.
|
# key is a deprecated version and value is an alternative version.
|
||||||
DEPRECATED_VERSIONS = {}
|
DEPRECATED_VERSIONS = {}
|
||||||
@@ -54,6 +55,15 @@ def launch_audit_forced(requested_version):
|
|||||||
APIVersion(MINOR_2_FORCE_AUDIT))
|
APIVersion(MINOR_2_FORCE_AUDIT))
|
||||||
|
|
||||||
|
|
||||||
|
def action_update_supported(requested_version):
|
||||||
|
"""Check if we should support action update functionality.
|
||||||
|
|
||||||
|
Version 1.5 of the API added support for updating action state.
|
||||||
|
"""
|
||||||
|
return (APIVersion(requested_version) >=
|
||||||
|
APIVersion(MINOR_5_ACTION_UPDATE))
|
||||||
|
|
||||||
|
|
||||||
class APIVersion(object):
|
class APIVersion(object):
|
||||||
"""This class represents an API Version Request.
|
"""This class represents an API Version Request.
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ from watcherclient import exceptions
|
|||||||
# Record the latest version that this client was tested with.
|
# Record the latest version that this client was tested with.
|
||||||
DEFAULT_VER = '1.latest'
|
DEFAULT_VER = '1.latest'
|
||||||
# Minor version 4 for adding webhook API
|
# Minor version 4 for adding webhook API
|
||||||
LAST_KNOWN_API_VERSION = 4
|
LAST_KNOWN_API_VERSION = 5
|
||||||
LATEST_VERSION = '1.{}'.format(LAST_KNOWN_API_VERSION)
|
LATEST_VERSION = '1.{}'.format(LAST_KNOWN_API_VERSION)
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def credentials():
|
|||||||
return [x for sub in creds_dict.items() for x in sub]
|
return [x for sub in creds_dict.items() for x in sub]
|
||||||
|
|
||||||
|
|
||||||
def execute(cmd, fail_ok=False, merge_stderr=False):
|
def execute(cmd, fail_ok=False, merge_stderr=True):
|
||||||
"""Executes specified command for the given action."""
|
"""Executes specified command for the given action."""
|
||||||
cmdlist = shlex.split(cmd)
|
cmdlist = shlex.split(cmd)
|
||||||
cmdlist.extend(credentials())
|
cmdlist.extend(credentials())
|
||||||
|
|||||||
@@ -79,3 +79,142 @@ class ActionTests(base.TestCase):
|
|||||||
self.assertIn(action_uuid, action)
|
self.assertIn(action_uuid, action)
|
||||||
self.assert_table_structure([action],
|
self.assert_table_structure([action],
|
||||||
self.detailed_list_fields)
|
self.detailed_list_fields)
|
||||||
|
|
||||||
|
|
||||||
|
class ActionUpdateTests(base.TestCase):
|
||||||
|
"""Functional tests for action update functionality."""
|
||||||
|
|
||||||
|
# Use API version 1.5 for action update tests
|
||||||
|
api_version = 1.5
|
||||||
|
dummy_name = 'dummy'
|
||||||
|
audit_template_name = 'b' + uuidutils.generate_uuid()
|
||||||
|
audit_uuid = None
|
||||||
|
action_uuid = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
# Create audit template
|
||||||
|
template_raw_output = cls.watcher(
|
||||||
|
'audittemplate create %s dummy -s dummy' % cls.audit_template_name)
|
||||||
|
template_output = cls.parse_show_as_object(template_raw_output)
|
||||||
|
|
||||||
|
# Create audit
|
||||||
|
audit_output = cls.parse_show_as_object(cls.watcher(
|
||||||
|
'audit create -a %s' % template_output['Name']))
|
||||||
|
cls.audit_uuid = audit_output['UUID']
|
||||||
|
|
||||||
|
# Wait for audit to complete
|
||||||
|
audit_created = test_utils.call_until_true(
|
||||||
|
func=functools.partial(cls.has_audit_created, cls.audit_uuid),
|
||||||
|
duration=600,
|
||||||
|
sleep_for=2)
|
||||||
|
if not audit_created:
|
||||||
|
raise Exception('Audit has not been succeeded')
|
||||||
|
|
||||||
|
# Get an action to test updates on
|
||||||
|
action_list = cls.parse_show(cls.watcher('action list --audit %s'
|
||||||
|
% cls.audit_uuid))
|
||||||
|
if action_list:
|
||||||
|
cls.action_uuid = list(action_list[0])[0]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
# Clean up: Delete Action Plan and all related actions
|
||||||
|
if cls.audit_uuid:
|
||||||
|
output = cls.parse_show(
|
||||||
|
cls.watcher('actionplan list --audit %s' % cls.audit_uuid))
|
||||||
|
if output:
|
||||||
|
action_plan_uuid = list(output[0])[0]
|
||||||
|
raw_output = cls.watcher(
|
||||||
|
'actionplan delete %s' % action_plan_uuid)
|
||||||
|
cls.assertOutput('', raw_output)
|
||||||
|
|
||||||
|
# Delete audit
|
||||||
|
raw_output = cls.watcher('audit delete %s' % cls.audit_uuid)
|
||||||
|
cls.assertOutput('', raw_output)
|
||||||
|
|
||||||
|
# Delete template
|
||||||
|
raw_output = cls.watcher(
|
||||||
|
'audittemplate delete %s' % cls.audit_template_name)
|
||||||
|
cls.assertOutput('', raw_output)
|
||||||
|
|
||||||
|
def test_action_update_with_state_and_reason(self):
|
||||||
|
"""Test updating action state with reason using API 1.5"""
|
||||||
|
if not self.action_uuid:
|
||||||
|
self.skipTest("No actions available for testing")
|
||||||
|
|
||||||
|
# Update action state to SKIPPED with reason
|
||||||
|
raw_output = self.watcher(
|
||||||
|
'action update --state SKIPPED --reason "Functional test skip" %s'
|
||||||
|
% self.action_uuid)
|
||||||
|
|
||||||
|
# Verify the action was updated
|
||||||
|
action = self.parse_show_as_object(
|
||||||
|
self.watcher('action show %s' % self.action_uuid))
|
||||||
|
self.assertEqual('SKIPPED', action['State'])
|
||||||
|
self.assertEqual('Action skipped by user. Reason: Functional test '
|
||||||
|
'skip', action['Status Message'])
|
||||||
|
|
||||||
|
# Verify output contains the action UUID
|
||||||
|
self.assertIn(self.action_uuid, raw_output)
|
||||||
|
|
||||||
|
def test_action_update_with_state_only(self):
|
||||||
|
"""Test updating action state without reason"""
|
||||||
|
if not self.action_uuid:
|
||||||
|
self.skipTest("No actions available for testing")
|
||||||
|
|
||||||
|
# Update action state to SKIPPED without reason
|
||||||
|
raw_output = self.watcher(
|
||||||
|
'action update --state SKIPPED %s' % self.action_uuid)
|
||||||
|
|
||||||
|
# Verify the action was updated
|
||||||
|
action = self.parse_show_as_object(
|
||||||
|
self.watcher('action show %s' % self.action_uuid))
|
||||||
|
self.assertEqual('SKIPPED', action['State'])
|
||||||
|
|
||||||
|
# Verify output contains the action UUID
|
||||||
|
self.assertIn(self.action_uuid, raw_output)
|
||||||
|
|
||||||
|
def test_action_update_missing_state_fails(self):
|
||||||
|
"""Test that action update fails when no state is provided"""
|
||||||
|
if not self.action_uuid:
|
||||||
|
self.skipTest("No actions available for testing")
|
||||||
|
|
||||||
|
# This should fail because --state is required
|
||||||
|
raw_output = self.watcher(
|
||||||
|
'action update %s' % self.action_uuid, fail_ok=True)
|
||||||
|
|
||||||
|
# Should contain error message about missing state
|
||||||
|
self.assertIn(
|
||||||
|
'At least one field update is required for this operation',
|
||||||
|
raw_output)
|
||||||
|
|
||||||
|
def test_action_update_nonexistent_action_fails(self):
|
||||||
|
"""Test that action update fails for non-existent action"""
|
||||||
|
fake_uuid = uuidutils.generate_uuid()
|
||||||
|
|
||||||
|
# This should fail because the action doesn't exist
|
||||||
|
raw_output = self.watcher(
|
||||||
|
'action update --state SKIPPED %s' % fake_uuid, fail_ok=True)
|
||||||
|
|
||||||
|
# Should contain error message about action not found
|
||||||
|
self.assertIn('404', raw_output)
|
||||||
|
|
||||||
|
|
||||||
|
class ActionUpdateApiVersionTests(base.TestCase):
|
||||||
|
"""Test action update functionality with different API versions."""
|
||||||
|
|
||||||
|
# Use API version 1.0 to test version checking
|
||||||
|
api_version = 1.0
|
||||||
|
|
||||||
|
def test_action_update_unsupported_api_version(self):
|
||||||
|
"""Test that action update fails with API version < 1.5"""
|
||||||
|
fake_uuid = uuidutils.generate_uuid()
|
||||||
|
|
||||||
|
# This should fail because API version 1.0 doesn't support updates
|
||||||
|
raw_output = self.watcher(
|
||||||
|
'action update --state SKIPPED %s' % fake_uuid, fail_ok=True)
|
||||||
|
|
||||||
|
# Should contain error message about unsupported API version
|
||||||
|
self.assertIn('not supported in API version', raw_output)
|
||||||
|
self.assertIn('Minimum required version is 1.5', raw_output)
|
||||||
|
|||||||
@@ -148,3 +148,22 @@ class GetAPIVersionTestCase(utils.BaseTestCase):
|
|||||||
self.assertEqual(mock_apiversion.return_value,
|
self.assertEqual(mock_apiversion.return_value,
|
||||||
api_versioning.get_api_version(version))
|
api_versioning.get_api_version(version))
|
||||||
mock_apiversion.assert_called_once_with(version)
|
mock_apiversion.assert_called_once_with(version)
|
||||||
|
|
||||||
|
|
||||||
|
class APIVersionFunctionsTestCase(utils.BaseTestCase):
|
||||||
|
def test_action_update_supported_true(self):
|
||||||
|
# Test versions >= 1.5 support action update
|
||||||
|
self.assertTrue(api_versioning.action_update_supported("1.5"))
|
||||||
|
self.assertTrue(api_versioning.action_update_supported("1.6"))
|
||||||
|
self.assertTrue(api_versioning.action_update_supported("2.0"))
|
||||||
|
|
||||||
|
def test_action_update_supported_false(self):
|
||||||
|
# Test versions < 1.5 do not support action update
|
||||||
|
self.assertFalse(api_versioning.action_update_supported("1.0"))
|
||||||
|
self.assertFalse(api_versioning.action_update_supported("1.1"))
|
||||||
|
self.assertFalse(api_versioning.action_update_supported("1.4"))
|
||||||
|
|
||||||
|
def test_action_update_supported_edge_case(self):
|
||||||
|
# Test exact boundary
|
||||||
|
self.assertTrue(api_versioning.action_update_supported("1.5"))
|
||||||
|
self.assertFalse(api_versioning.action_update_supported("1.4"))
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ fake_responses = {
|
|||||||
{},
|
{},
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
|
'PATCH': (
|
||||||
|
{},
|
||||||
|
ACTION1,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
'/v1/actions/detail?action_plan_uuid=%s' % ACTION1['action_plan']:
|
'/v1/actions/detail?action_plan_uuid=%s' % ACTION1['action_plan']:
|
||||||
{
|
{
|
||||||
@@ -264,3 +268,12 @@ class ActionManagerTest(testtools.TestCase):
|
|||||||
self.assertEqual(ACTION1['uuid'], action.uuid)
|
self.assertEqual(ACTION1['uuid'], action.uuid)
|
||||||
self.assertEqual(ACTION1['action_plan'], action.action_plan)
|
self.assertEqual(ACTION1['action_plan'], action.action_plan)
|
||||||
self.assertEqual(ACTION1['next'], action.next)
|
self.assertEqual(ACTION1['next'], action.next)
|
||||||
|
|
||||||
|
def test_actions_update(self):
|
||||||
|
patch = [{'op': 'replace', 'path': '/state', 'value': 'SKIPPED'}]
|
||||||
|
action = self.mgr.update(ACTION1['uuid'], patch)
|
||||||
|
expect = [
|
||||||
|
('PATCH', '/v1/actions/%s' % ACTION1['uuid'], {}, patch),
|
||||||
|
]
|
||||||
|
self.assertEqual(expect, self.api.calls)
|
||||||
|
self.assertEqual(ACTION1['uuid'], action.uuid)
|
||||||
|
|||||||
@@ -79,8 +79,9 @@ class ActionPlanShellTest(base.CommandTestCase):
|
|||||||
FIELD_LABELS = resource_fields.ACTION_PLAN_FIELD_LABELS
|
FIELD_LABELS = resource_fields.ACTION_PLAN_FIELD_LABELS
|
||||||
GLOBAL_EFFICACY_FIELDS = resource_fields.GLOBAL_EFFICACY_FIELDS
|
GLOBAL_EFFICACY_FIELDS = resource_fields.GLOBAL_EFFICACY_FIELDS
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self, os_infra_optim_api_version='1.0'):
|
||||||
super(self.__class__, self).setUp()
|
super(ActionPlanShellTest, self).setUp(
|
||||||
|
os_infra_optim_api_version=os_infra_optim_api_version)
|
||||||
|
|
||||||
p_audit_manager = mock.patch.object(resource, 'AuditManager')
|
p_audit_manager = mock.patch.object(resource, 'AuditManager')
|
||||||
p_audit_template_manager = mock.patch.object(
|
p_audit_template_manager = mock.patch.object(
|
||||||
@@ -336,3 +337,14 @@ class ActionPlanShellTest(base.CommandTestCase):
|
|||||||
|
|
||||||
self.assertEqual(1, exit_code)
|
self.assertEqual(1, exit_code)
|
||||||
self.assertEqual('', result)
|
self.assertEqual('', result)
|
||||||
|
|
||||||
|
|
||||||
|
class ActionPlanShellTest15(ActionPlanShellTest):
|
||||||
|
def setUp(self):
|
||||||
|
super(ActionPlanShellTest15, self).setUp(
|
||||||
|
os_infra_optim_api_version='1.5')
|
||||||
|
v15 = dict(status_message=None)
|
||||||
|
for action_plan in (ACTION_PLAN_1, ACTION_PLAN_2):
|
||||||
|
action_plan.update(v15)
|
||||||
|
self.FIELDS.extend(['status_message'])
|
||||||
|
self.FIELD_LABELS.extend(['Status Message'])
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import io
|
import io
|
||||||
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from watcherclient import exceptions
|
from watcherclient import exceptions
|
||||||
@@ -78,8 +79,9 @@ class ActionShellTest(base.CommandTestCase):
|
|||||||
FIELDS = resource_fields.ACTION_FIELDS
|
FIELDS = resource_fields.ACTION_FIELDS
|
||||||
FIELD_LABELS = resource_fields.ACTION_FIELD_LABELS
|
FIELD_LABELS = resource_fields.ACTION_FIELD_LABELS
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self, os_infra_optim_api_version='1.0'):
|
||||||
super(self.__class__, self).setUp()
|
super(ActionShellTest, self).setUp(
|
||||||
|
os_infra_optim_api_version=os_infra_optim_api_version)
|
||||||
|
|
||||||
p_action_manager = mock.patch.object(resource, 'ActionManager')
|
p_action_manager = mock.patch.object(resource, 'ActionManager')
|
||||||
p_action_plan_manager = mock.patch.object(
|
p_action_plan_manager = mock.patch.object(
|
||||||
@@ -176,3 +178,106 @@ class ActionShellTest(base.CommandTestCase):
|
|||||||
|
|
||||||
self.assertEqual(1, exit_code)
|
self.assertEqual(1, exit_code)
|
||||||
self.assertEqual('', result)
|
self.assertEqual('', result)
|
||||||
|
|
||||||
|
def test_do_action_update_unsupported_version(self):
|
||||||
|
|
||||||
|
exit_code, result = self.run_cmd(
|
||||||
|
'action update --state SKIPPED '
|
||||||
|
'770ef053-ecb3-48b0-85b5-d55a2dbc6588',
|
||||||
|
formatting=None)
|
||||||
|
|
||||||
|
self.assertEqual(1, exit_code)
|
||||||
|
self.assertEqual('', result)
|
||||||
|
|
||||||
|
|
||||||
|
class ActionShellTest15(ActionShellTest):
|
||||||
|
def setUp(self):
|
||||||
|
super(ActionShellTest15, self).setUp(os_infra_optim_api_version='1.5')
|
||||||
|
v15 = dict(status_message=None)
|
||||||
|
for action in (ACTION_1, ACTION_2, ACTION_3):
|
||||||
|
action.update(v15)
|
||||||
|
self.FIELDS.extend(['status_message'])
|
||||||
|
self.FIELD_LABELS.extend(['Status Message'])
|
||||||
|
|
||||||
|
def test_do_action_update_with_state_only(self):
|
||||||
|
action = resource.Action(mock.Mock(), ACTION_1)
|
||||||
|
self.m_action_mgr.update.return_value = action
|
||||||
|
|
||||||
|
exit_code, result = self.run_cmd(
|
||||||
|
'action update --state SKIPPED '
|
||||||
|
'770ef053-ecb3-48b0-85b5-d55a2dbc6588')
|
||||||
|
|
||||||
|
self.assertEqual(0, exit_code)
|
||||||
|
self.assertEqual(
|
||||||
|
self.resource_as_dict(action, self.FIELDS, self.FIELD_LABELS),
|
||||||
|
result)
|
||||||
|
|
||||||
|
expected_patch = [
|
||||||
|
{'op': 'replace', 'path': '/state', 'value': 'SKIPPED'}
|
||||||
|
]
|
||||||
|
self.m_action_mgr.update.assert_called_once_with(
|
||||||
|
'770ef053-ecb3-48b0-85b5-d55a2dbc6588', expected_patch)
|
||||||
|
|
||||||
|
def test_do_action_update_with_state_and_reason(self):
|
||||||
|
action = resource.Action(mock.Mock(), ACTION_1)
|
||||||
|
self.m_action_mgr.update.return_value = action
|
||||||
|
|
||||||
|
exit_code, result = self.run_cmd(
|
||||||
|
'action update --state SKIPPED --reason "Manual skip" '
|
||||||
|
'770ef053-ecb3-48b0-85b5-d55a2dbc6588')
|
||||||
|
|
||||||
|
self.assertEqual(0, exit_code)
|
||||||
|
self.assertEqual(
|
||||||
|
self.resource_as_dict(action, self.FIELDS, self.FIELD_LABELS),
|
||||||
|
result)
|
||||||
|
|
||||||
|
expected_patch = [
|
||||||
|
{'op': 'replace', 'path': '/state', 'value': 'SKIPPED'},
|
||||||
|
{'op': 'replace', 'path': '/status_message',
|
||||||
|
'value': 'Manual skip'}
|
||||||
|
]
|
||||||
|
self.m_action_mgr.update.assert_called_once_with(
|
||||||
|
'770ef053-ecb3-48b0-85b5-d55a2dbc6588', expected_patch)
|
||||||
|
|
||||||
|
def test_do_action_update_with_reason_only(self):
|
||||||
|
action = resource.Action(mock.Mock(), ACTION_1)
|
||||||
|
self.m_action_mgr.update.return_value = action
|
||||||
|
|
||||||
|
exit_code, result = self.run_cmd(
|
||||||
|
'action update --reason "Manual skip" '
|
||||||
|
'770ef053-ecb3-48b0-85b5-d55a2dbc6588')
|
||||||
|
|
||||||
|
self.assertEqual(0, exit_code)
|
||||||
|
self.assertEqual(
|
||||||
|
self.resource_as_dict(action, self.FIELDS, self.FIELD_LABELS),
|
||||||
|
result)
|
||||||
|
|
||||||
|
expected_patch = [
|
||||||
|
{'op': 'replace', 'path': '/status_message',
|
||||||
|
'value': 'Manual skip'}
|
||||||
|
]
|
||||||
|
self.m_action_mgr.update.assert_called_once_with(
|
||||||
|
'770ef053-ecb3-48b0-85b5-d55a2dbc6588', expected_patch)
|
||||||
|
|
||||||
|
def test_do_action_update_no_fields_to_update(self):
|
||||||
|
exit_code, result = self.run_cmd(
|
||||||
|
'action update 770ef053-ecb3-48b0-85b5-d55a2dbc6588',
|
||||||
|
formatting=None)
|
||||||
|
|
||||||
|
self.assertEqual(1, exit_code)
|
||||||
|
self.assertEqual('', result)
|
||||||
|
|
||||||
|
def test_do_action_update_action_not_found(self):
|
||||||
|
|
||||||
|
self.m_action_mgr.update.side_effect = exceptions.HTTPNotFound
|
||||||
|
|
||||||
|
exit_code, result = self.run_cmd(
|
||||||
|
'action update --state SKIPPED not_found_uuid',
|
||||||
|
formatting=None)
|
||||||
|
|
||||||
|
self.assertEqual(1, exit_code)
|
||||||
|
self.assertEqual('', result)
|
||||||
|
|
||||||
|
@unittest.skip("Action update is supported in API version 1.5")
|
||||||
|
def test_do_action_update_unsupported_version(self):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -494,8 +494,9 @@ class AuditShellTestv11(AuditShellTest):
|
|||||||
|
|
||||||
|
|
||||||
class AuditShellTestv12(AuditShellTest):
|
class AuditShellTestv12(AuditShellTest):
|
||||||
def setUp(self):
|
def setUp(self, os_infra_optim_api_version='1.2'):
|
||||||
super(AuditShellTestv12, self).setUp(os_infra_optim_api_version='1.2')
|
super(AuditShellTestv12, self).setUp(
|
||||||
|
os_infra_optim_api_version=os_infra_optim_api_version)
|
||||||
v11 = dict(start_time=None, end_time=None)
|
v11 = dict(start_time=None, end_time=None)
|
||||||
v12 = dict(force=False)
|
v12 = dict(force=False)
|
||||||
for audit in (self.AUDIT_1, self.AUDIT_2, self.AUDIT_3):
|
for audit in (self.AUDIT_1, self.AUDIT_2, self.AUDIT_3):
|
||||||
@@ -697,3 +698,13 @@ class AuditShellTestv12(AuditShellTest):
|
|||||||
name='my_audit',
|
name='my_audit',
|
||||||
force=False
|
force=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditShellTestv15(AuditShellTestv12):
|
||||||
|
def setUp(self):
|
||||||
|
super(AuditShellTestv15, self).setUp(os_infra_optim_api_version='1.5')
|
||||||
|
v15 = dict(status_message=None)
|
||||||
|
for audit in (self.AUDIT_1, self.AUDIT_2, self.AUDIT_3):
|
||||||
|
audit.update(v15)
|
||||||
|
self.FIELDS.extend(['status_message'])
|
||||||
|
self.FIELD_LABELS.extend(['Status Message'])
|
||||||
|
|||||||
@@ -83,3 +83,6 @@ class ActionManager(base.Manager):
|
|||||||
return self._list(self._path(action_id))[0]
|
return self._list(self._path(action_id))[0]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def update(self, action_id, patch):
|
||||||
|
return self._update(self._path(action_id), patch)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
import copy
|
||||||
import io
|
import io
|
||||||
|
|
||||||
from cliff.formatters import yaml_format
|
from cliff.formatters import yaml_format
|
||||||
@@ -20,12 +21,23 @@ from osc_lib import utils
|
|||||||
from oslo_utils import uuidutils
|
from oslo_utils import uuidutils
|
||||||
|
|
||||||
from watcherclient._i18n import _
|
from watcherclient._i18n import _
|
||||||
|
from watcherclient.common import api_versioning
|
||||||
from watcherclient.common import command
|
from watcherclient.common import command
|
||||||
from watcherclient.common import utils as common_utils
|
from watcherclient.common import utils as common_utils
|
||||||
from watcherclient import exceptions
|
from watcherclient import exceptions
|
||||||
from watcherclient.v1 import resource_fields as res_fields
|
from watcherclient.v1 import resource_fields as res_fields
|
||||||
|
|
||||||
|
|
||||||
|
def drop_unsupported_field(app_args, fields, field_labels):
|
||||||
|
fields = copy.copy(fields)
|
||||||
|
field_labels = copy.copy(field_labels)
|
||||||
|
api_ver = app_args.os_infra_optim_api_version
|
||||||
|
if not api_versioning.action_update_supported(api_ver):
|
||||||
|
fields.remove('status_message')
|
||||||
|
field_labels.remove('Status Message')
|
||||||
|
return fields, field_labels
|
||||||
|
|
||||||
|
|
||||||
def format_global_efficacy(global_efficacy):
|
def format_global_efficacy(global_efficacy):
|
||||||
formatted_global_eff = {}
|
formatted_global_eff = {}
|
||||||
for eff in global_efficacy:
|
for eff in global_efficacy:
|
||||||
@@ -101,6 +113,8 @@ class ShowActionPlan(command.ShowOne):
|
|||||||
|
|
||||||
columns = res_fields.ACTION_PLAN_FIELDS
|
columns = res_fields.ACTION_PLAN_FIELDS
|
||||||
column_headers = res_fields.ACTION_PLAN_FIELD_LABELS
|
column_headers = res_fields.ACTION_PLAN_FIELD_LABELS
|
||||||
|
columns, column_headers = drop_unsupported_field(
|
||||||
|
self.app_args, columns, column_headers)
|
||||||
return column_headers, utils.get_item_properties(action_plan, columns)
|
return column_headers, utils.get_item_properties(action_plan, columns)
|
||||||
|
|
||||||
|
|
||||||
@@ -178,6 +192,8 @@ class ListActionPlan(command.Lister):
|
|||||||
if parsed_args.detail:
|
if parsed_args.detail:
|
||||||
fields = res_fields.ACTION_PLAN_FIELDS
|
fields = res_fields.ACTION_PLAN_FIELDS
|
||||||
field_labels = res_fields.ACTION_PLAN_FIELD_LABELS
|
field_labels = res_fields.ACTION_PLAN_FIELD_LABELS
|
||||||
|
fields, field_labels = drop_unsupported_field(
|
||||||
|
self.app_args, fields, field_labels)
|
||||||
else:
|
else:
|
||||||
fields = res_fields.ACTION_PLAN_SHORT_LIST_FIELDS
|
fields = res_fields.ACTION_PLAN_SHORT_LIST_FIELDS
|
||||||
field_labels = res_fields.ACTION_PLAN_SHORT_LIST_FIELD_LABELS
|
field_labels = res_fields.ACTION_PLAN_SHORT_LIST_FIELD_LABELS
|
||||||
@@ -239,6 +255,8 @@ class UpdateActionPlan(command.ShowOne):
|
|||||||
|
|
||||||
columns = res_fields.ACTION_PLAN_FIELDS
|
columns = res_fields.ACTION_PLAN_FIELDS
|
||||||
column_headers = res_fields.ACTION_PLAN_FIELD_LABELS
|
column_headers = res_fields.ACTION_PLAN_FIELD_LABELS
|
||||||
|
columns, column_headers = drop_unsupported_field(
|
||||||
|
self.app_args, columns, column_headers)
|
||||||
|
|
||||||
return column_headers, utils.get_item_properties(action_plan, columns)
|
return column_headers, utils.get_item_properties(action_plan, columns)
|
||||||
|
|
||||||
@@ -265,6 +283,8 @@ class StartActionPlan(command.ShowOne):
|
|||||||
|
|
||||||
columns = res_fields.ACTION_PLAN_FIELDS
|
columns = res_fields.ACTION_PLAN_FIELDS
|
||||||
column_headers = res_fields.ACTION_PLAN_FIELD_LABELS
|
column_headers = res_fields.ACTION_PLAN_FIELD_LABELS
|
||||||
|
columns, column_headers = drop_unsupported_field(
|
||||||
|
self.app_args, columns, column_headers)
|
||||||
|
|
||||||
return column_headers, utils.get_item_properties(action_plan, columns)
|
return column_headers, utils.get_item_properties(action_plan, columns)
|
||||||
|
|
||||||
@@ -314,5 +334,7 @@ class CancelActionPlan(command.ShowOne):
|
|||||||
|
|
||||||
columns = res_fields.ACTION_PLAN_FIELDS
|
columns = res_fields.ACTION_PLAN_FIELDS
|
||||||
column_headers = res_fields.ACTION_PLAN_FIELD_LABELS
|
column_headers = res_fields.ACTION_PLAN_FIELD_LABELS
|
||||||
|
columns, column_headers = drop_unsupported_field(
|
||||||
|
self.app_args, columns, column_headers)
|
||||||
|
|
||||||
return column_headers, utils.get_item_properties(action_plan, columns)
|
return column_headers, utils.get_item_properties(action_plan, columns)
|
||||||
|
|||||||
@@ -13,15 +13,29 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
import copy
|
||||||
|
|
||||||
from osc_lib import utils
|
from osc_lib import utils
|
||||||
|
from oslo_utils import uuidutils
|
||||||
|
|
||||||
from watcherclient._i18n import _
|
from watcherclient._i18n import _
|
||||||
|
from watcherclient.common import api_versioning
|
||||||
from watcherclient.common import command
|
from watcherclient.common import command
|
||||||
from watcherclient.common import utils as common_utils
|
from watcherclient.common import utils as common_utils
|
||||||
from watcherclient import exceptions
|
from watcherclient import exceptions
|
||||||
from watcherclient.v1 import resource_fields as res_fields
|
from watcherclient.v1 import resource_fields as res_fields
|
||||||
|
|
||||||
|
|
||||||
|
def drop_unsupported_field(app_args, fields, field_labels):
|
||||||
|
fields = copy.copy(fields)
|
||||||
|
field_labels = copy.copy(field_labels)
|
||||||
|
api_ver = app_args.os_infra_optim_api_version
|
||||||
|
if not api_versioning.action_update_supported(api_ver):
|
||||||
|
fields.remove('status_message')
|
||||||
|
field_labels.remove('Status Message')
|
||||||
|
return fields, field_labels
|
||||||
|
|
||||||
|
|
||||||
class ShowAction(command.ShowOne):
|
class ShowAction(command.ShowOne):
|
||||||
"""Show detailed information about a given action."""
|
"""Show detailed information about a given action."""
|
||||||
|
|
||||||
@@ -44,6 +58,8 @@ class ShowAction(command.ShowOne):
|
|||||||
|
|
||||||
columns = res_fields.ACTION_FIELDS
|
columns = res_fields.ACTION_FIELDS
|
||||||
column_headers = res_fields.ACTION_FIELD_LABELS
|
column_headers = res_fields.ACTION_FIELD_LABELS
|
||||||
|
columns, column_headers = drop_unsupported_field(
|
||||||
|
self.app_args, columns, column_headers)
|
||||||
|
|
||||||
return column_headers, utils.get_item_properties(action, columns)
|
return column_headers, utils.get_item_properties(action, columns)
|
||||||
|
|
||||||
@@ -104,6 +120,8 @@ class ListAction(command.Lister):
|
|||||||
if parsed_args.detail:
|
if parsed_args.detail:
|
||||||
fields = res_fields.ACTION_FIELDS
|
fields = res_fields.ACTION_FIELDS
|
||||||
field_labels = res_fields.ACTION_FIELD_LABELS
|
field_labels = res_fields.ACTION_FIELD_LABELS
|
||||||
|
fields, field_labels = drop_unsupported_field(
|
||||||
|
self.app_args, fields, field_labels)
|
||||||
else:
|
else:
|
||||||
fields = res_fields.ACTION_SHORT_LIST_FIELDS
|
fields = res_fields.ACTION_SHORT_LIST_FIELDS
|
||||||
field_labels = res_fields.ACTION_SHORT_LIST_FIELD_LABELS
|
field_labels = res_fields.ACTION_SHORT_LIST_FIELD_LABELS
|
||||||
@@ -119,3 +137,67 @@ class ListAction(command.Lister):
|
|||||||
|
|
||||||
return (field_labels,
|
return (field_labels,
|
||||||
(utils.get_item_properties(item, fields) for item in data))
|
(utils.get_item_properties(item, fields) for item in data))
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateAction(command.ShowOne):
|
||||||
|
"""Update action command."""
|
||||||
|
|
||||||
|
def get_parser(self, prog_name):
|
||||||
|
parser = super(UpdateAction, self).get_parser(prog_name)
|
||||||
|
parser.add_argument(
|
||||||
|
'action',
|
||||||
|
metavar='<action>',
|
||||||
|
help=_('UUID of the action'))
|
||||||
|
parser.add_argument(
|
||||||
|
'--state',
|
||||||
|
metavar='<state>',
|
||||||
|
help=_('New state for the action (e.g., SKIPPED)'))
|
||||||
|
parser.add_argument(
|
||||||
|
'--reason',
|
||||||
|
metavar='<reason>',
|
||||||
|
help=_('Reason for the state change'))
|
||||||
|
return parser
|
||||||
|
|
||||||
|
def take_action(self, parsed_args):
|
||||||
|
client = getattr(self.app.client_manager, "infra-optim")
|
||||||
|
|
||||||
|
# Check if action update is supported in the requested API version
|
||||||
|
api_ver = self.app_args.os_infra_optim_api_version
|
||||||
|
if not api_versioning.action_update_supported(api_ver):
|
||||||
|
raise exceptions.CommandError(
|
||||||
|
_("Action update is not supported in API version %s. "
|
||||||
|
"Minimum required version is 1.5.") % api_ver)
|
||||||
|
|
||||||
|
if not parsed_args.state and not parsed_args.reason:
|
||||||
|
raise exceptions.CommandError(
|
||||||
|
_("At least one field update is required for this operation"))
|
||||||
|
|
||||||
|
if not uuidutils.is_uuid_like(parsed_args.action):
|
||||||
|
raise exceptions.ValidationError()
|
||||||
|
|
||||||
|
patch = []
|
||||||
|
if parsed_args.state:
|
||||||
|
patch.append({
|
||||||
|
'op': 'replace',
|
||||||
|
'path': '/state',
|
||||||
|
'value': parsed_args.state
|
||||||
|
})
|
||||||
|
|
||||||
|
if parsed_args.reason:
|
||||||
|
patch.append({
|
||||||
|
'op': 'replace',
|
||||||
|
'path': '/status_message',
|
||||||
|
'value': parsed_args.reason
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = client.action.update(parsed_args.action, patch)
|
||||||
|
except exceptions.HTTPNotFound as exc:
|
||||||
|
raise exceptions.CommandError(str(exc))
|
||||||
|
|
||||||
|
columns = res_fields.ACTION_FIELDS
|
||||||
|
column_headers = res_fields.ACTION_FIELD_LABELS
|
||||||
|
columns, column_headers = drop_unsupported_field(
|
||||||
|
self.app_args, columns, column_headers)
|
||||||
|
|
||||||
|
return column_headers, utils.get_item_properties(action, columns)
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ def drop_unsupported_field(app_args, fields, field_labels):
|
|||||||
if not api_versioning.launch_audit_forced(api_ver):
|
if not api_versioning.launch_audit_forced(api_ver):
|
||||||
fields.remove('force')
|
fields.remove('force')
|
||||||
field_labels.remove('Force')
|
field_labels.remove('Force')
|
||||||
|
if not api_versioning.action_update_supported(api_ver):
|
||||||
|
fields.remove('status_message')
|
||||||
|
field_labels.remove('Status Message')
|
||||||
return fields, field_labels
|
return fields, field_labels
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,13 +33,14 @@ AUDIT_TEMPLATE_SHORT_LIST_FIELD_LABELS = ['UUID', 'Name', 'Goal', 'Strategy']
|
|||||||
AUDIT_FIELDS = ['uuid', 'name', 'created_at', 'updated_at', 'deleted_at',
|
AUDIT_FIELDS = ['uuid', 'name', 'created_at', 'updated_at', 'deleted_at',
|
||||||
'state', 'audit_type', 'parameters', 'interval', 'goal_name',
|
'state', 'audit_type', 'parameters', 'interval', 'goal_name',
|
||||||
'strategy_name', 'scope', 'auto_trigger', 'next_run_time',
|
'strategy_name', 'scope', 'auto_trigger', 'next_run_time',
|
||||||
'hostname', 'start_time', 'end_time', 'force']
|
'hostname', 'start_time', 'end_time', 'force',
|
||||||
|
'status_message']
|
||||||
|
|
||||||
AUDIT_FIELD_LABELS = ['UUID', 'Name', 'Created At', 'Updated At', 'Deleted At',
|
AUDIT_FIELD_LABELS = ['UUID', 'Name', 'Created At', 'Updated At', 'Deleted At',
|
||||||
'State', 'Audit Type', 'Parameters', 'Interval', 'Goal',
|
'State', 'Audit Type', 'Parameters', 'Interval', 'Goal',
|
||||||
'Strategy', 'Audit Scope', 'Auto Trigger',
|
'Strategy', 'Audit Scope', 'Auto Trigger',
|
||||||
'Next Run Time', 'Hostname', 'Start Time', 'End Time',
|
'Next Run Time', 'Hostname', 'Start Time', 'End Time',
|
||||||
'Force']
|
'Force', 'Status Message']
|
||||||
|
|
||||||
AUDIT_SHORT_LIST_FIELDS = ['uuid', 'name', 'audit_type',
|
AUDIT_SHORT_LIST_FIELDS = ['uuid', 'name', 'audit_type',
|
||||||
'state', 'goal_name', 'strategy_name',
|
'state', 'goal_name', 'strategy_name',
|
||||||
@@ -51,12 +52,13 @@ AUDIT_SHORT_LIST_FIELD_LABELS = ['UUID', 'Name', 'Audit Type', 'State', 'Goal',
|
|||||||
# Action Plan
|
# Action Plan
|
||||||
ACTION_PLAN_FIELDS = ['uuid', 'created_at', 'updated_at', 'deleted_at',
|
ACTION_PLAN_FIELDS = ['uuid', 'created_at', 'updated_at', 'deleted_at',
|
||||||
'audit_uuid', 'strategy_name', 'state',
|
'audit_uuid', 'strategy_name', 'state',
|
||||||
'efficacy_indicators', 'global_efficacy', 'hostname']
|
'efficacy_indicators', 'global_efficacy', 'hostname',
|
||||||
|
'status_message']
|
||||||
|
|
||||||
ACTION_PLAN_FIELD_LABELS = ['UUID', 'Created At', 'Updated At', 'Deleted At',
|
ACTION_PLAN_FIELD_LABELS = ['UUID', 'Created At', 'Updated At', 'Deleted At',
|
||||||
'Audit', 'Strategy', 'State',
|
'Audit', 'Strategy', 'State',
|
||||||
'Efficacy indicators', 'Global efficacy',
|
'Efficacy indicators', 'Global efficacy',
|
||||||
'Hostname']
|
'Hostname', 'Status Message']
|
||||||
|
|
||||||
ACTION_PLAN_SHORT_LIST_FIELDS = ['uuid', 'audit_uuid', 'state',
|
ACTION_PLAN_SHORT_LIST_FIELDS = ['uuid', 'audit_uuid', 'state',
|
||||||
'updated_at', 'global_efficacy']
|
'updated_at', 'global_efficacy']
|
||||||
@@ -69,11 +71,11 @@ GLOBAL_EFFICACY_FIELDS = ['value', 'unit', 'name', 'description']
|
|||||||
# Action
|
# Action
|
||||||
ACTION_FIELDS = ['uuid', 'created_at', 'updated_at', 'deleted_at', 'parents',
|
ACTION_FIELDS = ['uuid', 'created_at', 'updated_at', 'deleted_at', 'parents',
|
||||||
'state', 'action_plan_uuid', 'action_type',
|
'state', 'action_plan_uuid', 'action_type',
|
||||||
'input_parameters', 'description']
|
'input_parameters', 'description', 'status_message']
|
||||||
|
|
||||||
ACTION_FIELD_LABELS = ['UUID', 'Created At', 'Updated At', 'Deleted At',
|
ACTION_FIELD_LABELS = ['UUID', 'Created At', 'Updated At', 'Deleted At',
|
||||||
'Parents', 'State', 'Action Plan', 'Action',
|
'Parents', 'State', 'Action Plan', 'Action',
|
||||||
'Parameters', 'Description']
|
'Parameters', 'Description', 'Status Message']
|
||||||
|
|
||||||
ACTION_SHORT_LIST_FIELDS = ['uuid', 'parents',
|
ACTION_SHORT_LIST_FIELDS = ['uuid', 'parents',
|
||||||
'state', 'action_plan_uuid', 'action_type']
|
'state', 'action_plan_uuid', 'action_type']
|
||||||
|
|||||||
Reference in New Issue
Block a user