From d17bfa04ada4c830d2fa18e995c523e3e930328d Mon Sep 17 00:00:00 2001 From: Alfredo Moralejo Date: Fri, 8 Aug 2025 11:33:57 +0200 Subject: [PATCH] Add action update command to support skipping actions manually This commit introduces the "openstack optimize action update" command that allows cloud admins to manually update action states. The only current use case is to mark actions as SKIPPED before starting an action plan. Additionally, the option `--reason` can be used to provide a text that will be stored as part of the status_message field. Command usage: openstack optimize action update --state SKIPPED --reason "reason" The feature requires Watcher API microversion 1.5 or higher and includes automatic version checking. In order to assert specific strings in stderr, I'm enabling `merge_stderr` option by default in execute. It's totally backwards compatible, so I'm not parametrizing it. Implements: blueprint add-skip-actions Assisted-By: Claude (claude-sonnet-4) Depends-On: https://review.opendev.org/c/openstack/watcher/+/955753/ Change-Id: Ice88c0ab58c0cfd784c707620da89a891055ffc2 Signed-off-by: Alfredo Moralejo --- doc/source/cli/details.rst | 33 ++++- ...ction-update-command-b8c74d5e6f234a21.yaml | 24 +++ setup.cfg | 2 + watcherclient/__init__.py | 2 +- watcherclient/common/api_versioning.py | 10 ++ watcherclient/common/httpclient.py | 2 +- .../tests/client_functional/v1/base.py | 2 +- .../tests/client_functional/v1/test_action.py | 139 ++++++++++++++++++ .../tests/unit/common/test_api_versioning.py | 19 +++ watcherclient/tests/unit/v1/test_action.py | 13 ++ .../tests/unit/v1/test_action_plan_shell.py | 16 +- .../tests/unit/v1/test_action_shell.py | 109 +++++++++++++- .../tests/unit/v1/test_audit_shell.py | 15 +- watcherclient/v1/action.py | 3 + watcherclient/v1/action_plan_shell.py | 22 +++ watcherclient/v1/action_shell.py | 82 +++++++++++ watcherclient/v1/audit_shell.py | 3 + watcherclient/v1/resource_fields.py | 14 +- 18 files changed, 494 insertions(+), 16 deletions(-) create mode 100644 releasenotes/notes/add-action-update-command-b8c74d5e6f234a21.yaml 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']