diff --git a/doc/source/cli/details.rst b/doc/source/cli/details.rst index 21f5977..ab4c73b 100644 --- a/doc/source/cli/details.rst +++ b/doc/source/cli/details.rst @@ -24,7 +24,7 @@ The watcher client is the command-line interface (CLI) for the Infrastructure Optimization service (watcher) API 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: @@ -214,6 +214,37 @@ Show detailed information about a given action. ``-h, --help`` 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 ] [--fit-width] + [--print-empty] [--noindent] [--prefix PREFIX] + [--state ] [--reason ] + +Update action command. + +**Positional arguments:** + +```` + UUID of the action + +**Optional arguments:** + +``-h, --help`` + show this help message and exit + +``--state `` + New state for the action (e.g., SKIPPED) + +``--reason `` + Reason for the action state change. + + .. _watcher_actionplan_cancel: watcher actionplan cancel diff --git a/releasenotes/notes/add-action-update-command-b8c74d5e6f234a21.yaml b/releasenotes/notes/add-action-update-command-b8c74d5e6f234a21.yaml new file mode 100644 index 0000000..1661b31 --- /dev/null +++ b/releasenotes/notes/add-action-update-command-b8c74d5e6f234a21.yaml @@ -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 `` - New state for the action (required) + * ``--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" + + 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. diff --git a/setup.cfg b/setup.cfg index 82d469e..84dbba4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -60,6 +60,7 @@ openstack.infra_optim.v1 = optimize_action_show = watcherclient.v1.action_shell:ShowAction 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_list = watcherclient.v1.scoring_engine_shell:ListScoringEngine @@ -99,6 +100,7 @@ watcherclient.v1 = action_show = watcherclient.v1.action_shell:ShowAction action_list = watcherclient.v1.action_shell:ListAction + action_update = watcherclient.v1.action_shell:UpdateAction scoringengine_show = watcherclient.v1.scoring_engine_shell:ShowScoringEngine scoringengine_list = watcherclient.v1.scoring_engine_shell:ListScoringEngine diff --git a/watcherclient/__init__.py b/watcherclient/__init__.py index 5492c60..4383e17 100644 --- a/watcherclient/__init__.py +++ b/watcherclient/__init__.py @@ -32,4 +32,4 @@ API_MIN_VERSION = api_versioning.APIVersion("1.0") # when client supported the max version, and bumped sequentially, otherwise # the client may break due to server side new version may include some # backward incompatible change. -API_MAX_VERSION = api_versioning.APIVersion("1.1") +API_MAX_VERSION = api_versioning.APIVersion("1.5") diff --git a/watcherclient/common/api_versioning.py b/watcherclient/common/api_versioning.py index 9edcff9..35634dc 100644 --- a/watcherclient/common/api_versioning.py +++ b/watcherclient/common/api_versioning.py @@ -28,6 +28,7 @@ if not LOG.handlers: MINOR_1_START_END_TIMING = '1.1' MINOR_2_FORCE_AUDIT = '1.2' +MINOR_5_ACTION_UPDATE = '1.5' HEADER_NAME = "OpenStack-API-Version" # key is a deprecated version and value is an alternative version. DEPRECATED_VERSIONS = {} @@ -54,6 +55,15 @@ def launch_audit_forced(requested_version): 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): """This class represents an API Version Request. diff --git a/watcherclient/common/httpclient.py b/watcherclient/common/httpclient.py index a2325a1..8231e58 100644 --- a/watcherclient/common/httpclient.py +++ b/watcherclient/common/httpclient.py @@ -41,7 +41,7 @@ from watcherclient import exceptions # Record the latest version that this client was tested with. DEFAULT_VER = '1.latest' # 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) LOG = logging.getLogger(__name__) diff --git a/watcherclient/tests/client_functional/v1/base.py b/watcherclient/tests/client_functional/v1/base.py index 69e1240..c0bd1b2 100644 --- a/watcherclient/tests/client_functional/v1/base.py +++ b/watcherclient/tests/client_functional/v1/base.py @@ -34,7 +34,7 @@ def credentials(): 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.""" cmdlist = shlex.split(cmd) cmdlist.extend(credentials()) diff --git a/watcherclient/tests/client_functional/v1/test_action.py b/watcherclient/tests/client_functional/v1/test_action.py index 1a12ab2..a37eeec 100644 --- a/watcherclient/tests/client_functional/v1/test_action.py +++ b/watcherclient/tests/client_functional/v1/test_action.py @@ -79,3 +79,142 @@ class ActionTests(base.TestCase): self.assertIn(action_uuid, action) self.assert_table_structure([action], 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) diff --git a/watcherclient/tests/unit/common/test_api_versioning.py b/watcherclient/tests/unit/common/test_api_versioning.py index 84a23d1..3369eb3 100644 --- a/watcherclient/tests/unit/common/test_api_versioning.py +++ b/watcherclient/tests/unit/common/test_api_versioning.py @@ -148,3 +148,22 @@ class GetAPIVersionTestCase(utils.BaseTestCase): self.assertEqual(mock_apiversion.return_value, api_versioning.get_api_version(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")) diff --git a/watcherclient/tests/unit/v1/test_action.py b/watcherclient/tests/unit/v1/test_action.py index 9f0b53d..b1f76db 100644 --- a/watcherclient/tests/unit/v1/test_action.py +++ b/watcherclient/tests/unit/v1/test_action.py @@ -92,6 +92,10 @@ fake_responses = { {}, None, ), + 'PATCH': ( + {}, + ACTION1, + ), }, '/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['action_plan'], action.action_plan) 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) diff --git a/watcherclient/tests/unit/v1/test_action_plan_shell.py b/watcherclient/tests/unit/v1/test_action_plan_shell.py index cdeeac5..68f7877 100644 --- a/watcherclient/tests/unit/v1/test_action_plan_shell.py +++ b/watcherclient/tests/unit/v1/test_action_plan_shell.py @@ -79,8 +79,9 @@ class ActionPlanShellTest(base.CommandTestCase): FIELD_LABELS = resource_fields.ACTION_PLAN_FIELD_LABELS GLOBAL_EFFICACY_FIELDS = resource_fields.GLOBAL_EFFICACY_FIELDS - def setUp(self): - super(self.__class__, self).setUp() + def setUp(self, os_infra_optim_api_version='1.0'): + super(ActionPlanShellTest, self).setUp( + os_infra_optim_api_version=os_infra_optim_api_version) p_audit_manager = mock.patch.object(resource, 'AuditManager') p_audit_template_manager = mock.patch.object( @@ -336,3 +337,14 @@ class ActionPlanShellTest(base.CommandTestCase): self.assertEqual(1, exit_code) 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']) diff --git a/watcherclient/tests/unit/v1/test_action_shell.py b/watcherclient/tests/unit/v1/test_action_shell.py index e29ff32..7c2fde1 100644 --- a/watcherclient/tests/unit/v1/test_action_shell.py +++ b/watcherclient/tests/unit/v1/test_action_shell.py @@ -15,6 +15,7 @@ import datetime import io +import unittest from unittest import mock from watcherclient import exceptions @@ -78,8 +79,9 @@ class ActionShellTest(base.CommandTestCase): FIELDS = resource_fields.ACTION_FIELDS FIELD_LABELS = resource_fields.ACTION_FIELD_LABELS - def setUp(self): - super(self.__class__, self).setUp() + def setUp(self, os_infra_optim_api_version='1.0'): + super(ActionShellTest, self).setUp( + os_infra_optim_api_version=os_infra_optim_api_version) p_action_manager = mock.patch.object(resource, 'ActionManager') p_action_plan_manager = mock.patch.object( @@ -176,3 +178,106 @@ class ActionShellTest(base.CommandTestCase): self.assertEqual(1, exit_code) 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 diff --git a/watcherclient/tests/unit/v1/test_audit_shell.py b/watcherclient/tests/unit/v1/test_audit_shell.py index aa953c2..1b8c363 100644 --- a/watcherclient/tests/unit/v1/test_audit_shell.py +++ b/watcherclient/tests/unit/v1/test_audit_shell.py @@ -494,8 +494,9 @@ class AuditShellTestv11(AuditShellTest): class AuditShellTestv12(AuditShellTest): - def setUp(self): - super(AuditShellTestv12, self).setUp(os_infra_optim_api_version='1.2') + def setUp(self, 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) v12 = dict(force=False) for audit in (self.AUDIT_1, self.AUDIT_2, self.AUDIT_3): @@ -697,3 +698,13 @@ class AuditShellTestv12(AuditShellTest): name='my_audit', 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']) diff --git a/watcherclient/v1/action.py b/watcherclient/v1/action.py index 64dcb6f..13fabd4 100644 --- a/watcherclient/v1/action.py +++ b/watcherclient/v1/action.py @@ -83,3 +83,6 @@ class ActionManager(base.Manager): return self._list(self._path(action_id))[0] except IndexError: return None + + def update(self, action_id, patch): + return self._update(self._path(action_id), patch) diff --git a/watcherclient/v1/action_plan_shell.py b/watcherclient/v1/action_plan_shell.py index 6fc9ca5..b38f7e5 100644 --- a/watcherclient/v1/action_plan_shell.py +++ b/watcherclient/v1/action_plan_shell.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy import io from cliff.formatters import yaml_format @@ -20,12 +21,23 @@ from osc_lib import utils from oslo_utils import uuidutils from watcherclient._i18n import _ +from watcherclient.common import api_versioning from watcherclient.common import command from watcherclient.common import utils as common_utils from watcherclient import exceptions 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): formatted_global_eff = {} for eff in global_efficacy: @@ -101,6 +113,8 @@ class ShowActionPlan(command.ShowOne): columns = res_fields.ACTION_PLAN_FIELDS 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) @@ -178,6 +192,8 @@ class ListActionPlan(command.Lister): if parsed_args.detail: fields = res_fields.ACTION_PLAN_FIELDS field_labels = res_fields.ACTION_PLAN_FIELD_LABELS + fields, field_labels = drop_unsupported_field( + self.app_args, fields, field_labels) else: fields = res_fields.ACTION_PLAN_SHORT_LIST_FIELDS field_labels = res_fields.ACTION_PLAN_SHORT_LIST_FIELD_LABELS @@ -239,6 +255,8 @@ class UpdateActionPlan(command.ShowOne): columns = res_fields.ACTION_PLAN_FIELDS 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) @@ -265,6 +283,8 @@ class StartActionPlan(command.ShowOne): columns = res_fields.ACTION_PLAN_FIELDS 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) @@ -314,5 +334,7 @@ class CancelActionPlan(command.ShowOne): columns = res_fields.ACTION_PLAN_FIELDS 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) diff --git a/watcherclient/v1/action_shell.py b/watcherclient/v1/action_shell.py index 87976af..1a2deb1 100644 --- a/watcherclient/v1/action_shell.py +++ b/watcherclient/v1/action_shell.py @@ -13,15 +13,29 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy + from osc_lib import utils +from oslo_utils import uuidutils from watcherclient._i18n import _ +from watcherclient.common import api_versioning from watcherclient.common import command from watcherclient.common import utils as common_utils from watcherclient import exceptions 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): """Show detailed information about a given action.""" @@ -44,6 +58,8 @@ class ShowAction(command.ShowOne): 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) @@ -104,6 +120,8 @@ class ListAction(command.Lister): if parsed_args.detail: fields = res_fields.ACTION_FIELDS field_labels = res_fields.ACTION_FIELD_LABELS + fields, field_labels = drop_unsupported_field( + self.app_args, fields, field_labels) else: fields = res_fields.ACTION_SHORT_LIST_FIELDS field_labels = res_fields.ACTION_SHORT_LIST_FIELD_LABELS @@ -119,3 +137,67 @@ class ListAction(command.Lister): return (field_labels, (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='', + help=_('UUID of the action')) + parser.add_argument( + '--state', + metavar='', + help=_('New state for the action (e.g., SKIPPED)')) + parser.add_argument( + '--reason', + metavar='', + 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) diff --git a/watcherclient/v1/audit_shell.py b/watcherclient/v1/audit_shell.py index f9b1196..7098ab5 100644 --- a/watcherclient/v1/audit_shell.py +++ b/watcherclient/v1/audit_shell.py @@ -38,6 +38,9 @@ def drop_unsupported_field(app_args, fields, field_labels): if not api_versioning.launch_audit_forced(api_ver): fields.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 diff --git a/watcherclient/v1/resource_fields.py b/watcherclient/v1/resource_fields.py index 2bfef2e..50f2312 100644 --- a/watcherclient/v1/resource_fields.py +++ b/watcherclient/v1/resource_fields.py @@ -33,13 +33,14 @@ AUDIT_TEMPLATE_SHORT_LIST_FIELD_LABELS = ['UUID', 'Name', 'Goal', 'Strategy'] AUDIT_FIELDS = ['uuid', 'name', 'created_at', 'updated_at', 'deleted_at', 'state', 'audit_type', 'parameters', 'interval', 'goal_name', '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', 'State', 'Audit Type', 'Parameters', 'Interval', 'Goal', 'Strategy', 'Audit Scope', 'Auto Trigger', 'Next Run Time', 'Hostname', 'Start Time', 'End Time', - 'Force'] + 'Force', 'Status Message'] AUDIT_SHORT_LIST_FIELDS = ['uuid', 'name', 'audit_type', 'state', 'goal_name', 'strategy_name', @@ -51,12 +52,13 @@ AUDIT_SHORT_LIST_FIELD_LABELS = ['UUID', 'Name', 'Audit Type', 'State', 'Goal', # Action Plan ACTION_PLAN_FIELDS = ['uuid', 'created_at', 'updated_at', 'deleted_at', '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', 'Audit', 'Strategy', 'State', 'Efficacy indicators', 'Global efficacy', - 'Hostname'] + 'Hostname', 'Status Message'] ACTION_PLAN_SHORT_LIST_FIELDS = ['uuid', 'audit_uuid', 'state', 'updated_at', 'global_efficacy'] @@ -69,11 +71,11 @@ GLOBAL_EFFICACY_FIELDS = ['value', 'unit', 'name', 'description'] # Action ACTION_FIELDS = ['uuid', 'created_at', 'updated_at', 'deleted_at', 'parents', 'state', 'action_plan_uuid', 'action_type', - 'input_parameters', 'description'] + 'input_parameters', 'description', 'status_message'] ACTION_FIELD_LABELS = ['UUID', 'Created At', 'Updated At', 'Deleted At', 'Parents', 'State', 'Action Plan', 'Action', - 'Parameters', 'Description'] + 'Parameters', 'Description', 'Status Message'] ACTION_SHORT_LIST_FIELDS = ['uuid', 'parents', 'state', 'action_plan_uuid', 'action_type']