From a986fbdcb9aecf3d644a537865892156115055d5 Mon Sep 17 00:00:00 2001 From: minwooseo Date: Wed, 9 Apr 2025 16:58:17 +0900 Subject: [PATCH] Add `api-call` action for ironic inspection rule This patch introduces a new rule action plugin, `api-call`, to trigger a webhook (via HTTP GET) when rule matching completes successfully. It enables external integrations like alerts or automation triggers. This feature supports: - Timeout configuration (default: 5 seconds) - Automatic retry with backoff (default: 3 times, backoff factor 0.3) - Optional custom headers and proxy settings Retry is applied for HTTP status codes: 429, 500, 502, 503, 504. This continues the effort initially reviewed in ironic-inspector: https://review.opendev.org/c/openstack/ironic-inspector/+/942968 New rule usage example: [ { "description": "Trigger webhook after introspection", "actions": [ { "action": "api-call", "url": "http://example.com/hook", "timeout": 10, "retries": 5, "backoff_factor": 1 } ] } ] Change-Id: I59e14ef77430477fe029f35e157d70d4af307ac1 --- ironic/common/inspection_rules/actions.py | 47 +++++++++++++++++++ .../tests/unit/common/test_inspection_rule.py | 21 +++++++++ ...ll-inspection-action-985aee4347ed9217.yaml | 36 ++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 releasenotes/notes/add-api-call-inspection-action-985aee4347ed9217.yaml diff --git a/ironic/common/inspection_rules/actions.py b/ironic/common/inspection_rules/actions.py index 2e4e11327d..edd08fa624 100644 --- a/ironic/common/inspection_rules/actions.py +++ b/ironic/common/inspection_rules/actions.py @@ -11,6 +11,9 @@ # under the License. import abc +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry from oslo_log import log @@ -39,6 +42,7 @@ ACTIONS = { "set-port-attribute": "SetPortAttributeAction", "extend-port-attribute": "ExtendPortAttributeAction", "del-port-attribute": "DelPortAttributeAction", + "api-call": "CallAPIHookAction", } @@ -407,3 +411,46 @@ class DelPortAttributeAction(ActionBase): 'path': path, 'port_id': port_id, 'exc': str(exc)} LOG.error(msg) raise exception.RuleActionExecutionFailure(reason=msg) + + +class CallAPIHookAction(ActionBase): + FORMATTED_ARGS = ['url'] + OPTIONAL_PARAMS = [ + 'headers', 'proxies', 'timeout', 'retries', 'backoff_factor' + ] + + def __call__(self, task, url, headers=None, proxies=None, + timeout=5, retries=3, backoff_factor=0.3): + try: + timeout = float(timeout) + if timeout <= 0: + raise ValueError("timeout must be greater than zero") + retries = int(retries) + backoff_factor = float(backoff_factor) + retry_strategy = Retry( + total=retries, + backoff_factor=backoff_factor, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["GET"], + raise_on_status=False + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + session = requests.Session() + session.mount("http://", adapter) + session.mount("https://", adapter) + request_kwargs = {} + if headers: + request_kwargs['headers'] = headers + if proxies: + request_kwargs['proxies'] = proxies + response = session.get(url, timeout=timeout, **request_kwargs) + response.raise_for_status() + except ValueError as exc: + msg = _("Invalid parameter: %s") % exc + LOG.error(msg) + raise exception.RuleActionExecutionFailure(reason=msg) + except requests.exceptions.RequestException as exc: + msg = _("Request to %(url)s failed: %(exc)s") % { + 'url': url, 'exc': exc} + LOG.error(msg) + raise exception.RuleActionExecutionFailure(reason=msg) diff --git a/ironic/tests/unit/common/test_inspection_rule.py b/ironic/tests/unit/common/test_inspection_rule.py index d7cbe42991..5f906c44d4 100644 --- a/ironic/tests/unit/common/test_inspection_rule.py +++ b/ironic/tests/unit/common/test_inspection_rule.py @@ -693,6 +693,27 @@ class TestActions(TestInspectionRules): self.assertEqual('value1', task.node.extra['test1']) self.assertEqual('value2', task.node.extra['test2']) + @mock.patch( + 'ironic.common.inspection_rules.actions.requests.Session', + autospec=True) + def test_call_api_hook_action_success(self, mock_session): + """Test CallAPIHookAction successfully calls an API.""" + mock_session_instance = mock_session.return_value + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.raise_for_status.return_value = None + mock_session_instance.get.return_value = mock_response + + with task_manager.acquire(self.context, self.node.uuid) as task: + action = inspection_rules.actions.CallAPIHookAction() + test_url = 'http://example.com/simple_hook' + action(task, url=test_url) + mock_session_instance.mount.assert_any_call("http://", mock.ANY) + mock_session_instance.mount.assert_any_call("https://", mock.ANY) + mock_session_instance.get.assert_called_once_with( + test_url, timeout=5) + mock_response.raise_for_status.assert_called_once() + class TestShallowMask(TestInspectionRules): def setUp(self): diff --git a/releasenotes/notes/add-api-call-inspection-action-985aee4347ed9217.yaml b/releasenotes/notes/add-api-call-inspection-action-985aee4347ed9217.yaml new file mode 100644 index 0000000000..cd222abf56 --- /dev/null +++ b/releasenotes/notes/add-api-call-inspection-action-985aee4347ed9217.yaml @@ -0,0 +1,36 @@ +--- +features: + - | + Added a new 'api-call' action plugin for Ironic inspection rules. + + This action allows triggering an HTTP GET request to a given URL when a + rule matches successfully during node inspection. It is useful for + integrating with external systems such as webhooks, alerting, or + automation tools. + + The following options are supported: + + * url (required): The HTTP endpoint to call + * timeout (optional, default: 5): Timeout in seconds + * retries (optional, default: 3): Number of retries on failure + * backoff_factor (optional, default: 0.3): Delay factor for retry attempts + * headers, proxies (optional): Additional request configuration + + Retry applies to status codes 429, 500, 502, 503, and 504. + + Example rule:: + + [ + { + "description": "Trigger webhook after node inspection", + "actions": [ + { + "action": "api-call", + "url": "http://example.com/hook", + "timeout": 10, + "retries": 5, + "backoff_factor": 1 + } + ] + } + ]