From 6012d2623e89666148ccdfd3950aaf8325e8bdd4 Mon Sep 17 00:00:00 2001 From: xshen1 Date: Sun, 10 Sep 2023 01:41:55 -0400 Subject: [PATCH] feat: pagerduty_alert: Adds in use of v2 api provided (#7183) * feat: pagerduty_alert: Adds in use of v2 api provided * doc: Adds xishen1 to maintainer * Pagerduty_alert: documentation change * pagerduty_alert: update documentation * pagerduty_alert: update periods * pagerduty_alert: update documentation --- .github/BOTMETA.yml | 2 +- .../fragments/update-v2-pagerduty-alert.yml | 2 + plugins/modules/pagerduty_alert.py | 302 ++++++++++++++---- .../plugins/modules/test_pagerduty_alert.py | 107 +++++++ 4 files changed, 344 insertions(+), 69 deletions(-) create mode 100644 changelogs/fragments/update-v2-pagerduty-alert.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 67ff467105..ee7cc20fc1 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -937,7 +937,7 @@ files: labels: pagerduty maintainers: suprememoocow thaumos $modules/pagerduty_alert.py: - maintainers: ApsOps + maintainers: ApsOps xshen1 $modules/pagerduty_change.py: maintainers: adamvaughan $modules/pagerduty_user.py: diff --git a/changelogs/fragments/update-v2-pagerduty-alert.yml b/changelogs/fragments/update-v2-pagerduty-alert.yml new file mode 100644 index 0000000000..1f34b17fcb --- /dev/null +++ b/changelogs/fragments/update-v2-pagerduty-alert.yml @@ -0,0 +1,2 @@ +minor_changes: + - pagerduty - adds in option to use v2 API for creating pagerduty incidents (https://github.com/ansible-collections/community.general/issues/6151) diff --git a/plugins/modules/pagerduty_alert.py b/plugins/modules/pagerduty_alert.py index 66013b94a0..de451051f1 100644 --- a/plugins/modules/pagerduty_alert.py +++ b/plugins/modules/pagerduty_alert.py @@ -16,6 +16,7 @@ description: - This module will let you trigger, acknowledge or resolve a PagerDuty incident by sending events author: - "Amanpreet Singh (@ApsOps)" + - "Xiao Shen (@xshen1)" requirements: - PagerDuty API access extends_documentation_fragment: @@ -30,20 +31,25 @@ options: type: str description: - PagerDuty unique subdomain. Obsolete. It is not used with PagerDuty REST v2 API. + api_key: + type: str + description: + - The pagerduty API key (readonly access), generated on the pagerduty site. + - Required if O(api_version=v1). + integration_key: + type: str + description: + - The GUID of one of your 'Generic API' services. + - This is the 'integration key' listed on a 'Integrations' tab of PagerDuty service. service_id: type: str description: - ID of PagerDuty service when incidents will be triggered, acknowledged or resolved. - required: true + - Required if O(api_version=v1). service_key: type: str description: - - The GUID of one of your "Generic API" services. Obsolete. Please use O(integration_key). - integration_key: - type: str - description: - - The GUID of one of your "Generic API" services. - - This is the "integration key" listed on a "Integrations" tab of PagerDuty service. + - The GUID of one of your 'Generic API' services. Obsolete. Please use O(integration_key). state: type: str description: @@ -53,30 +59,17 @@ options: - 'triggered' - 'acknowledged' - 'resolved' - api_key: + api_version: type: str description: - - The pagerduty API key (readonly access), generated on the pagerduty site. - required: true - desc: - type: str - description: - - For O(state=triggered) - Required. Short description of the problem that led to this trigger. This field (or a truncated version) - will be used when generating phone calls, SMS messages and alert emails. It will also appear on the incidents tables in the PagerDuty UI. - The maximum length is 1024 characters. - - For O(state=acknowledged) or O(state=resolved) - Text that will appear in the incident's log associated with this event. - required: false - default: Created via Ansible - incident_key: - type: str - description: - - Identifies the incident to which this O(state) should be applied. - - For O(state=triggered) - If there's no open (i.e. unresolved) incident with this key, a new one will be created. If there's already an - open incident with a matching key, this event will be appended to that incident's log. The event key provides an easy way to "de-dup" - problem reports. - - For O(state=acknowledged) or O(state=resolved) - This should be the incident_key you received back when the incident was first opened by a - trigger event. Acknowledge events referencing resolved or nonexistent incidents will be discarded. - required: false + - The API version we want to use to run the module. + - V1 is more limited with option we can provide to trigger incident. + - V2 has more variables for example, O(severity), O(source), O(custom_details), etc. + default: 'v1' + choices: + - 'v1' + - 'v2' + version_added: 7.4.0 client: type: str description: @@ -87,6 +80,75 @@ options: description: - The URL of the monitoring client that is triggering this event. required: false + component: + type: str + description: + - Component of the source machine that is responsible for the event, for example C(mysql) or C(eth0). + required: false + version_added: 7.4.0 + custom_details: + type: dict + description: + - Additional details about the event and affected system. + - A dictionary with custom keys and values. + required: false + version_added: 7.4.0 + desc: + type: str + description: + - For O(state=triggered) - Required. Short description of the problem that led to this trigger. This field (or a truncated version) + will be used when generating phone calls, SMS messages and alert emails. It will also appear on the incidents tables in the PagerDuty UI. + The maximum length is 1024 characters. + - For O(state=acknowledged) or O(state=resolved) - Text that will appear in the incident's log associated with this event. + required: false + default: Created via Ansible + incident_class: + type: str + description: + - The class/type of the event, for example C(ping failure) or C(cpu load). + required: false + version_added: 7.4.0 + incident_key: + type: str + description: + - Identifies the incident to which this O(state) should be applied. + - For O(state=triggered) - If there's no open (i.e. unresolved) incident with this key, a new one will be created. If there's already an + open incident with a matching key, this event will be appended to that incident's log. The event key provides an easy way to 'de-dup' + problem reports. If no O(incident_key) is provided, then it will be generated by PagerDuty. + - For O(state=acknowledged) or O(state=resolved) - This should be the incident_key you received back when the incident was first opened by a + trigger event. Acknowledge events referencing resolved or nonexistent incidents will be discarded. + required: false + link_url: + type: str + description: + - Relevant link url to the alert. For example, the website or the job link. + required: false + version_added: 7.4.0 + link_text: + type: str + description: + - A short decription of the link_url. + required: false + version_added: 7.4.0 + source: + type: str + description: + - The unique location of the affected system, preferably a hostname or FQDN. + - Required in case of O(state=trigger) and O(api_version=v2). + required: false + version_added: 7.4.0 + severity: + type: str + description: + - The perceived severity of the status the event is describing with respect to the affected system. + - Required in case of O(state=trigger) and O(api_version=v2). + default: 'critical' + choices: + - 'critical' + - 'warning' + - 'error' + - 'info' + version_added: 7.4.0 ''' EXAMPLES = ''' @@ -127,12 +189,50 @@ EXAMPLES = ''' state: resolved incident_key: somekey desc: "some text for incident's log" + +- name: Trigger an v2 incident with just the basic options + community.general.pagerduty_alert: + integration_key: xxx + api_version: v2 + source: My Ansible Script + state: triggered + desc: problem that led to this trigger + +- name: Trigger an v2 incident with more options + community.general.pagerduty_alert: + integration_key: xxx + api_version: v2 + source: My Ansible Script + state: triggered + desc: problem that led to this trigger + incident_key: somekey + client: Sample Monitoring Service + client_url: http://service.example.com + component: mysql + incident_class: ping failure + link_url: https://pagerduty.com + link_text: PagerDuty + +- name: Acknowledge an incident based on incident_key using v2 + community.general.pagerduty_alert: + api_version: v2 + integration_key: xxx + incident_key: somekey + state: acknowledged + +- name: Resolve an incident based on incident_key + community.general.pagerduty_alert: + api_version: v2 + integration_key: xxx + incident_key: somekey + state: resolved ''' import json from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.urls import fetch_url from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode, urlunparse +from datetime import datetime def check(module, name, state, service_id, integration_key, api_key, incident_key=None, http_call=fetch_url): @@ -175,8 +275,8 @@ def check(module, name, state, service_id, integration_key, api_key, incident_ke return incidents[0], False -def send_event(module, service_key, event_type, desc, - incident_key=None, client=None, client_url=None): +def send_event_v1(module, service_key, event_type, desc, + incident_key=None, client=None, client_url=None): url = "https://events.pagerduty.com/generic/2010-04-15/create_event.json" headers = { "Content-type": "application/json" @@ -200,61 +300,127 @@ def send_event(module, service_key, event_type, desc, return json_out +def send_event_v2(module, service_key, event_type, payload, link, + incident_key=None, client=None, client_url=None): + url = "https://events.pagerduty.com/v2/enqueue" + headers = { + "Content-type": "application/json" + } + data = { + "routing_key": service_key, + "event_action": event_type, + "payload": payload, + "client": client, + "client_url": client_url, + } + if link: + data["links"] = [link] + if incident_key: + data["dedup_key"] = incident_key + if event_type != "trigger": + data.pop("payload") + response, info = fetch_url(module, url, method="post", + headers=headers, data=json.dumps(data)) + if info["status"] != 202: + module.fail_json(msg="failed to %s. Reason: %s" % + (event_type, info['msg'])) + json_out = json.loads(response.read()) + return json_out, True + + def main(): module = AnsibleModule( argument_spec=dict( name=dict(required=False), - service_id=dict(required=True), - service_key=dict(required=False, no_log=True), + api_key=dict(required=False, no_log=True), integration_key=dict(required=False, no_log=True), - api_key=dict(required=True, no_log=True), - state=dict(required=True, - choices=['triggered', 'acknowledged', 'resolved']), - client=dict(required=False, default=None), - client_url=dict(required=False, default=None), + service_id=dict(required=False), + service_key=dict(required=False, no_log=True), + state=dict( + required=True, choices=['triggered', 'acknowledged', 'resolved'] + ), + api_version=dict(type='str', default='v1', choices=['v1', 'v2']), + client=dict(required=False), + client_url=dict(required=False), + component=dict(required=False), + custom_details=dict(required=False, type='dict'), desc=dict(required=False, default='Created via Ansible'), - incident_key=dict(required=False, default=None, no_log=False) + incident_class=dict(required=False), + incident_key=dict(required=False, no_log=False), + link_url=dict(required=False), + link_text=dict(required=False), + source=dict(required=False), + severity=dict( + default='critical', choices=['critical', 'warning', 'error', 'info'] + ), ), - supports_check_mode=True + required_if=[ + ('api_version', 'v1', ['service_id', 'api_key']), + ('state', 'acknowledged', ['incident_key']), + ('state', 'resolved', ['incident_key']), + ], + required_one_of=[('service_key', 'integration_key')], + supports_check_mode=True, ) name = module.params['name'] - service_id = module.params['service_id'] - integration_key = module.params['integration_key'] - service_key = module.params['service_key'] - api_key = module.params['api_key'] - state = module.params['state'] - client = module.params['client'] - client_url = module.params['client_url'] - desc = module.params['desc'] - incident_key = module.params['incident_key'] - + service_id = module.params.get('service_id') + integration_key = module.params.get('integration_key') + service_key = module.params.get('service_key') + api_key = module.params.get('api_key') + state = module.params.get('state') + client = module.params.get('client') + client_url = module.params.get('client_url') + desc = module.params.get('desc') + incident_key = module.params.get('incident_key') + payload = { + 'summary': desc, + 'source': module.params.get('source'), + 'timestamp': datetime.now().isoformat(), + 'severity': module.params.get('severity'), + 'component': module.params.get('component'), + 'class': module.params.get('incident_class'), + 'custom_details': module.params.get('custom_details'), + } + link = {} + if module.params.get('link_url'): + link['href'] = module.params.get('link_url') + if module.params.get('link_text'): + link['text'] = module.params.get('link_text') if integration_key is None: - if service_key is not None: - integration_key = service_key - module.warn('"service_key" is obsolete parameter and will be removed.' - ' Please, use "integration_key" instead') - else: - module.fail_json(msg="'integration_key' is required parameter") + integration_key = service_key + module.warn( + '"service_key" is obsolete parameter and will be removed.' + ' Please, use "integration_key" instead' + ) state_event_dict = { 'triggered': 'trigger', 'acknowledged': 'acknowledge', - 'resolved': 'resolve' + 'resolved': 'resolve', } event_type = state_event_dict[state] - - if event_type != 'trigger' and incident_key is None: - module.fail_json(msg="incident_key is required for " - "acknowledge or resolve events") - - out, changed = check(module, name, state, service_id, - integration_key, api_key, incident_key) - - if not module.check_mode and changed is True: - out = send_event(module, integration_key, event_type, desc, - incident_key, client, client_url) + if module.params.get('api_version') == 'v1': + out, changed = check(module, name, state, service_id, + integration_key, api_key, incident_key) + if not module.check_mode and changed is True: + out = send_event_v1(module, integration_key, event_type, desc, + incident_key, client, client_url) + else: + changed = True + if event_type == 'trigger' and not payload['source']: + module.fail_json(msg='"service" is a required variable for v2 api endpoint.') + out, changed = send_event_v2( + module, + integration_key, + event_type, + payload, + link, + incident_key, + client, + client_url, + ) module.exit_json(result=out, changed=changed) diff --git a/tests/unit/plugins/modules/test_pagerduty_alert.py b/tests/unit/plugins/modules/test_pagerduty_alert.py index 3df992b42d..958bd3fab7 100644 --- a/tests/unit/plugins/modules/test_pagerduty_alert.py +++ b/tests/unit/plugins/modules/test_pagerduty_alert.py @@ -7,6 +7,10 @@ __metaclass__ = type from ansible_collections.community.general.tests.unit.compat import unittest from ansible_collections.community.general.plugins.modules import pagerduty_alert +import json +import pytest +from ansible_collections.community.general.tests.unit.compat.mock import patch +from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args class PagerDutyAlertsTest(unittest.TestCase): @@ -44,3 +48,106 @@ class PagerDutyAlertsTest(unittest.TestCase): class Response(object): def read(self): return '{"incidents":[{"id": "incident_id", "status": "triggered"}]}' + + +class TestPagerDutyAlertModule(ModuleTestCase): + def setUp(self): + super(TestPagerDutyAlertModule, self).setUp() + self.module = pagerduty_alert + + def tearDown(self): + super(TestPagerDutyAlertModule, self).tearDown() + + @pytest.fixture + def fetch_url_mock(self, mocker): + return mocker.patch('ansible.module_utils.monitoring.pagerduty_change.fetch_url') + + def test_module_fail_when_required_args_missing(self): + with self.assertRaises(AnsibleFailJson): + set_module_args({}) + self.module.main() + + def test_ensure_alert_created_with_minimal_data(self): + set_module_args({ + 'state': 'triggered', + 'api_version': 'v2', + 'integration_key': 'test', + 'source': 'My Ansible Script', + 'desc': 'Description for alert' + }) + + with patch.object(pagerduty_alert, 'fetch_url') as fetch_url_mock: + fetch_url_mock.return_value = (Response(), {"status": 202}) + with self.assertRaises(AnsibleExitJson): + self.module.main() + + assert fetch_url_mock.call_count == 1 + url = fetch_url_mock.call_args[0][1] + json_data = fetch_url_mock.call_args[1]['data'] + data = json.loads(json_data) + + assert url == 'https://events.pagerduty.com/v2/enqueue' + assert data['routing_key'] == 'test' + assert data['event_action'] == 'trigger' + assert data['payload']['summary'] == 'Description for alert' + assert data['payload']['source'] == 'My Ansible Script' + assert data['payload']['severity'] == 'critical' + assert data['payload']['timestamp'] is not None + + def test_ensure_alert_created_with_full_data(self): + set_module_args({ + 'api_version': 'v2', + 'component': 'mysql', + 'custom_details': {'environment': 'production', 'notes': 'this is a test note'}, + 'desc': 'Description for alert', + 'incident_class': 'ping failure', + 'integration_key': 'test', + 'link_url': 'https://pagerduty.com', + 'link_text': 'PagerDuty', + 'state': 'triggered', + 'source': 'My Ansible Script', + }) + + with patch.object(pagerduty_alert, 'fetch_url') as fetch_url_mock: + fetch_url_mock.return_value = (Response(), {"status": 202}) + with self.assertRaises(AnsibleExitJson): + self.module.main() + + assert fetch_url_mock.call_count == 1 + url = fetch_url_mock.call_args[0][1] + json_data = fetch_url_mock.call_args[1]['data'] + data = json.loads(json_data) + + assert url == 'https://events.pagerduty.com/v2/enqueue' + assert data['routing_key'] == 'test' + assert data['payload']['summary'] == 'Description for alert' + assert data['payload']['source'] == 'My Ansible Script' + assert data['payload']['class'] == 'ping failure' + assert data['payload']['component'] == 'mysql' + assert data['payload']['custom_details']['environment'] == 'production' + assert data['payload']['custom_details']['notes'] == 'this is a test note' + assert data['links'][0]['href'] == 'https://pagerduty.com' + assert data['links'][0]['text'] == 'PagerDuty' + + def test_ensure_alert_acknowledged(self): + set_module_args({ + 'state': 'acknowledged', + 'api_version': 'v2', + 'integration_key': 'test', + 'incident_key': 'incident_test_id', + }) + + with patch.object(pagerduty_alert, 'fetch_url') as fetch_url_mock: + fetch_url_mock.return_value = (Response(), {"status": 202}) + with self.assertRaises(AnsibleExitJson): + self.module.main() + + assert fetch_url_mock.call_count == 1 + url = fetch_url_mock.call_args[0][1] + json_data = fetch_url_mock.call_args[1]['data'] + data = json.loads(json_data) + + assert url == 'https://events.pagerduty.com/v2/enqueue' + assert data['routing_key'] == 'test' + assert data['event_action'] == 'acknowledge' + assert data['dedup_key'] == 'incident_test_id'