diff --git a/changelogs/fragments/add grafana_silence module.yml b/changelogs/fragments/add grafana_silence module.yml new file mode 100644 index 00000000..b78be00d --- /dev/null +++ b/changelogs/fragments/add grafana_silence module.yml @@ -0,0 +1,2 @@ +major_changes: +- grafana_silence ; adding new module to create and delete silences through the API \ No newline at end of file diff --git a/meta/runtime.yml b/meta/runtime.yml index 15f5554f..d29f1dda 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -11,3 +11,4 @@ action_groups: - grafana_plugin - grafana_team - grafana_user + - grafana_silence diff --git a/plugins/modules/grafana_silence.py b/plugins/modules/grafana_silence.py new file mode 100644 index 00000000..151b3659 --- /dev/null +++ b/plugins/modules/grafana_silence.py @@ -0,0 +1,369 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +# Copyright: (c) 2023, flkhndlr (@flkhndlr) + +from __future__ import absolute_import, division, print_function + +DOCUMENTATION = """ +module: grafana_silence +author: + - flkhndlr (@flkhndlr) +version_added: "1.8.0" +short_description: Manage Grafana Silences +description: + - Create/delete Grafana Silences through the Alertmanager Silence API. +requirements: + - The Alertmanager API is only available starting Grafana 8 and the module will fail if the server version is lower than version 8. +options: + comment: + description: + - The comment that describes the silence. + required: true + type: str + created_by: + description: + - The author that creates the silence. + required: true + type: str + starts_at: + description: + - ISO 8601 Timestamp with milliseconds e.g. "2029-07-29T08:45:45.000Z" when the silence starts. + type: str + required: true + ends_at: + description: + - ISO 8601 Timestamp with milliseconds e.g. "2029-07-29T08:45:45.000Z" when the silence will end. + type: str + required: true + matchers: + description: + - List of matchers to select which alerts are affected by the silence. + type: list + elements: dict + required: true + state: + description: + - Delete the first occurrence of a silence with the same settings. Can be "absent" or "present". + default: present + type: str + choices: ["present", "absent"] + skip_version_check: + description: + - Skip Grafana version check and try to reach api endpoint anyway. + - This parameter can be useful if you enabled `hide_version` in grafana.ini + required: False + type: bool + default: False +extends_documentation_fragment: +- community.grafana.basic_auth +- community.grafana.api_key +""" + +EXAMPLES = """ +--- +- name: Create a silence + community.grafana.grafana_silence: + grafana_url: "https://grafana.example.com" + grafana_api_key: "{{ some_api_token_value }}" + comment: "a testcomment" + created_by: "me" + starts_at: "2029-07-29T08:45:45.000Z" + ends_at: "2029-07-29T08:55:45.000Z" + matchers: + - isEqual: true + isRegex: true + name: environment + value: test + state: present + +- name: Delete a silence + community.grafana.grafana_silence: + grafana_url: "https://grafana.example.com" + grafana_api_key: "{{ some_api_token_value }}" + comment: "a testcomment" + created_by: "me" + starts_at: "2029-07-29T08:45:45.000Z" + ends_at: "2029-07-29T08:55:45.000Z" + matchers: + - isEqual: true + isRegex: true + name: environment + value: test + state: absent +""" + +RETURN = """ +--- +silence: + description: Information about the silence + returned: On success + type: complex + contains: + id: + description: The id of the silence + returned: success + type: str + sample: + - ec27df6b-ac3c-412f-ae0b-6e3e1f41c9c3 + comment: + description: The comment of the silence + returned: success + type: str + sample: + - this is a test + createdBy: + description: The author of the silence + returned: success + type: str + sample: + - me + startsAt: + description: The begin timestamp of the silence + returned: success + type: str + sample: + - "2029-07-29T08:45:45.000Z" + endsAt: + description: The end timestamp of the silence + returned: success + type: str + sample: + - "2029-07-29T08:55:45.000Z" + matchers: + description: The matchers of the silence + returned: success + type: list + sample: + - [{"isEqual": true, "isRegex": true, "name": "environment", "value": "test"}] + status: + description: The status of the silence + returned: success + type: dict + sample: + - {"state": "pending"} + updatedAt: + description: The timestamp of the last update for the silence + returned: success + type: str + sample: + - "2023-07-27T13:27:33.042Z" +""" + +import json + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import fetch_url, basic_auth_header +from ansible.module_utils._text import to_text +from ansible_collections.community.grafana.plugins.module_utils import base + +__metaclass__ = type + + +class GrafanaError(Exception): + pass + + +class GrafanaSilenceInterface(object): + def __init__(self, module): + self._module = module + # {{{ Authentication header + self.headers = {"Content-Type": "application/json"} + module.params["force_basic_auth"] = True + if module.params.get("grafana_api_key", None): + self.headers["Authorization"] = ( + "Bearer %s" % module.params["grafana_api_key"] + ) + else: + self.headers["Authorization"] = basic_auth_header( + module.params["url_username"], module.params["url_password"] + ) + # }}} + self.grafana_url = base.clean_url(module.params.get("url")) + if module.params.get("skip_version_check") is False: + try: + grafana_version = self.get_version() + except GrafanaError as e: + self._module.fail_json(failed=True, msg=to_text(e)) + if grafana_version["major"] < 8: + self._module.fail_json( + failed=True, + msg="Silences API is available starting with Grafana v8", + ) + + def _send_request(self, url, data=None, headers=None, method="GET"): + if data is not None: + data = json.dumps(data) + if not headers: + headers = [] + + full_url = "{grafana_url}{path}".format(grafana_url=self.grafana_url, path=url) + resp, info = fetch_url( + self._module, full_url, data=data, headers=headers, method=method + ) + status_code = info["status"] + if status_code == 404: + return None + elif status_code == 401: + self._module.fail_json( + failed=True, + msg="Unauthorized to perform action '%s' on '%s'" % (method, full_url), + ) + elif status_code == 403: + self._module.fail_json(failed=True, msg="Permission Denied") + elif status_code in [200, 202]: + return self._module.from_json(resp.read()) + elif status_code == 400: + self._module.fail_json(failed=True, msg=info) + self._module.fail_json( + failed=True, msg="Grafana Silences API answered with HTTP %d" % status_code + ) + + def get_version(self): + url = "/api/health" + response = self._send_request( + url, data=None, headers=self.headers, method="GET" + ) + version = response.get("version") + if version is not None: + major, minor, rev = version.split(".") + return {"major": int(major), "minor": int(minor), "rev": int(rev)} + raise GrafanaError("Failed to retrieve version from '%s'" % url) + + def create_silence(self, comment, created_by, starts_at, ends_at, matchers): + url = "/api/alertmanager/grafana/api/v2/silences" + silence = dict( + comment=comment, + createdBy=created_by, + endsAt=ends_at, + matchers=matchers, + startsAt=starts_at, + ) + response = self._send_request( + url, data=silence, headers=self.headers, method="POST" + ) + if self.get_version()["major"] == 8: + response["silenceID"] = response["id"] + response.pop("id", None) + return response + + def get_silence(self, comment, created_by, starts_at, ends_at, matchers): + url = "/api/alertmanager/grafana/api/v2/silences" + + responses = self._send_request(url, headers=self.headers, method="GET") + + for response in responses: + if ( + response["comment"] == comment + and response["createdBy"] == created_by + and response["startsAt"] == starts_at + and response["endsAt"] == ends_at + and response["matchers"] == matchers + ): + return response + return None + + def get_silence_by_id(self, silence_id): + url = "/api/alertmanager/grafana/api/v2/silence/{SilenceId}".format( + SilenceId=silence_id + ) + response = self._send_request(url, headers=self.headers, method="GET") + return response + + def get_silences(self): + url = "/api/alertmanager/grafana/api/v2/silences" + response = self._send_request(url, headers=self.headers, method="GET") + return response + + def delete_silence(self, silence_id): + url = "/api/alertmanager/grafana/api/v2/silence/{SilenceId}".format( + SilenceId=silence_id + ) + response = self._send_request(url, headers=self.headers, method="DELETE") + return response + + +def setup_module_object(): + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + required_together=base.grafana_required_together(), + mutually_exclusive=base.grafana_mutually_exclusive(), + ) + return module + + +argument_spec = base.grafana_argument_spec() +argument_spec.update( + comment=dict(type="str", required=True), + state=dict(type="str", choices=["present", "absent"], default="present"), + created_by=dict(type="str", required=True), + starts_at=dict(type="str", required=True), + ends_at=dict(type="str", required=True), + matchers=dict(type="list", elements="dict", required=True), + skip_version_check=dict(type="bool", default=False), +) + + +def main(): + + module = setup_module_object() + comment = module.params["comment"] + created_by = module.params["created_by"] + starts_at = module.params["starts_at"] + ends_at = module.params["ends_at"] + matchers = module.params["matchers"] + state = module.params["state"] + + changed = False + failed = False + grafana_iface = GrafanaSilenceInterface(module) + + silence = grafana_iface.get_silence( + comment, created_by, starts_at, ends_at, matchers + ) + + if state == "present": + + if not silence: + silence = grafana_iface.create_silence( + comment, created_by, starts_at, ends_at, matchers + ) + silence = grafana_iface.get_silence_by_id(silence["silenceID"]) + changed = True + else: + module.exit_json( + failed=failed, + changed=changed, + msg="Silence with same parameters already exists! eg. '%s'" + % silence["id"], + ) + elif state == "absent": + if silence: + grafana_iface.delete_silence(silence["id"]) + changed = True + else: + module.exit_json( + failed=False, + changed=changed, + msg="Silence does not exist", + ) + module.exit_json(failed=failed, changed=changed, silence=silence) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/grafana_silence/defaults/main.yml b/tests/integration/targets/grafana_silence/defaults/main.yml new file mode 100644 index 00000000..4abf9bb4 --- /dev/null +++ b/tests/integration/targets/grafana_silence/defaults/main.yml @@ -0,0 +1,5 @@ +--- + +grafana_url: http://grafana:3000/ +grafana_username: admin +grafana_password: admin diff --git a/tests/integration/targets/grafana_silence/runme.sh b/tests/integration/targets/grafana_silence/runme.sh new file mode 100755 index 00000000..867afb0d --- /dev/null +++ b/tests/integration/targets/grafana_silence/runme.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -eux + +ansible-playbook site.yml diff --git a/tests/integration/targets/grafana_silence/site.yml b/tests/integration/targets/grafana_silence/site.yml new file mode 100644 index 00000000..79158227 --- /dev/null +++ b/tests/integration/targets/grafana_silence/site.yml @@ -0,0 +1,6 @@ +--- +- name: Run tests for grafana_silence + hosts: localhost + tasks: + - ansible.builtin.include_role: + name: ../../grafana_silence diff --git a/tests/integration/targets/grafana_silence/tasks/main.yml b/tests/integration/targets/grafana_silence/tasks/main.yml new file mode 100644 index 00000000..ad4cfa25 --- /dev/null +++ b/tests/integration/targets/grafana_silence/tasks/main.yml @@ -0,0 +1,89 @@ +--- +- name: Create new silence + community.grafana.grafana_silence: + url: "{{ grafana_url }}" + url_username: "{{ grafana_username }}" + url_password: "{{ grafana_password }}" + comment: "a testcomment" + created_by: "me" + starts_at: "2029-07-29T08:45:45.000Z" + ends_at: "2029-07-29T08:55:45.000Z" + matchers: + - isEqual: true + isRegex: true + name: environment + value: test + state: present + register: result +- assert: + that: + - "result.changed == true" + - "result.failed == false" + - "result.silence.id != ''" + +- name: Check idempotency on silence creation + community.grafana.grafana_silence: + url: "{{ grafana_url }}" + url_username: "{{ grafana_username }}" + url_password: "{{ grafana_password }}" + comment: "a testcomment" + created_by: "me" + starts_at: "2029-07-29T08:45:45.000Z" + ends_at: "2029-07-29T08:55:45.000Z" + matchers: + - isEqual: true + isRegex: true + name: environment + value: test + state: present + register: result +- assert: + that: + - "result.changed == false" + - "result.msg != ''" + +- name: Delete the silence + community.grafana.grafana_silence: + url: "{{ grafana_url }}" + url_username: "{{ grafana_username }}" + url_password: "{{ grafana_password }}" + comment: "a testcomment" + created_by: "me" + starts_at: "2029-07-29T08:45:45.000Z" + ends_at: "2029-07-29T08:55:45.000Z" + matchers: + - isEqual: true + isRegex: true + name: environment + value: test + state: absent + register: result +- assert: + that: + - "result.changed == true" + - "result.failed == false" + - "result.silence.id != ''" + - - "result.silence.createdBy != 'me'" + +- name: Check idempotency on silence deletion + community.grafana.grafana_silence: + url: "{{ grafana_url }}" + url_username: "{{ grafana_username }}" + url_password: "{{ grafana_password }}" + comment: "a testcomment" + created_by: "me" + starts_at: "2029-07-29T08:45:45.000Z" + ends_at: "2029-07-29T08:55:45.000Z" + matchers: + - isEqual: true + isRegex: true + name: environment + value: test + state: absent + register: result + ignore_errors: yes +- assert: + that: + - "result.changed == false" + - "result.failed == false" + - "result.msg == 'Silence does not exist'" \ No newline at end of file diff --git a/tests/unit/modules/grafana/grafana_silence/test_grafana_silence.py b/tests/unit/modules/grafana/grafana_silence/test_grafana_silence.py new file mode 100644 index 00000000..96522c2a --- /dev/null +++ b/tests/unit/modules/grafana/grafana_silence/test_grafana_silence.py @@ -0,0 +1,211 @@ +from __future__ import absolute_import, division, print_function + +from unittest import TestCase +from unittest.mock import patch +from ansible_collections.community.grafana.plugins.modules import grafana_silence +from ansible.module_utils._text import to_bytes +from ansible.module_utils import basic +from ansible.module_utils.urls import basic_auth_header +import json + +__metaclass__ = type + + +class MockedReponse(object): + def __init__(self, data): + self.data = data + + def read(self): + return self.data + + +def exit_json(*args, **kwargs): + """function to patch over exit_json; package return data into an exception""" + if "changed" not in kwargs: + kwargs["changed"] = False + raise AnsibleExitJson(kwargs) + + +def fail_json(*args, **kwargs): + """function to patch over fail_json; package return data into an exception""" + kwargs["failed"] = True + raise AnsibleFailJson(kwargs) + + +class AnsibleExitJson(Exception): + """Exception class to be raised by module.exit_json and caught by the test case""" + + pass + + +class AnsibleFailJson(Exception): + """Exception class to be raised by module.fail_json and caught by the test case""" + + pass + + +def set_module_args(args): + """prepare arguments so that they will be picked up during module creation""" + args = json.dumps({"ANSIBLE_MODULE_ARGS": args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +def silence_deleted_resp(): + server_response = json.dumps({"message": "silence deleted"}) + return (MockedReponse(server_response), {"status": 200}) + + +def silence_created_resp(): + server_response = json.dumps({"silenceID": "470b7116-8f06-4bb6-9e6c-6258aa92218e"}) + return (MockedReponse(server_response), {"status": 200}) + + +def silence_get_resp(): + server_response = json.dumps([], sort_keys=True) + return (MockedReponse(server_response), {"status": 200}) + + +def get_silence_by_id_resp(): + server_response = json.dumps([], sort_keys=True) + return (MockedReponse(server_response), {"status": 200}) + + +def get_version_resp(): + return {"major": 10, "minor": 0, "rev": 0} + + +class GrafanaSilenceTest(TestCase): + def setUp(self): + self.authorization = basic_auth_header("admin", "changeme") + self.mock_module_helper = patch.multiple( + basic.AnsibleModule, exit_json=exit_json, fail_json=fail_json + ) + self.mock_module_helper.start() + self.addCleanup(self.mock_module_helper.stop) + + # create a new silence + @patch( + "ansible_collections.community.grafana.plugins.modules.grafana_silence.GrafanaSilenceInterface.get_silence" + ) + @patch( + "ansible_collections.community.grafana.plugins.modules.grafana_silence.GrafanaSilenceInterface.get_version" + ) + @patch( + "ansible_collections.community.grafana.plugins.modules.grafana_silence.fetch_url" + ) + def test_create_silence_new_silence( + self, mock_fetch_url, mock_get_version, mock_get_silence + ): + set_module_args( + { + "url": "https://grafana.example.com", + "url_username": "admin", + "url_password": "changeme", + "comment": "a testcomment", + "created_by": "me", + "starts_at": "2029-07-29T08:45:45.000Z", + "ends_at": "2029-07-29T08:55:45.000Z", + "matchers": [ + { + "isEqual": True, + "isRegex": True, + "name": "environment", + "value": "test", + } + ], + "state": "present", + } + ) + module = grafana_silence.setup_module_object() + mock_get_version.return_value = get_version_resp() + mock_fetch_url.return_value = silence_created_resp() + mock_get_silence.return_value = silence_get_resp() + + grafana_iface = grafana_silence.GrafanaSilenceInterface(module) + result = grafana_iface.create_silence( + "a testcomment", + "me", + "2029-07-29T08:45:45.000Z", + "2029-07-29T08:55:45.000Z", + [ + { + "isEqual": True, + "isRegex": True, + "name": "environment", + "value": "test", + } + ], + ) + mock_fetch_url.assert_called_once_with( + module, + "https://grafana.example.com/api/alertmanager/grafana/api/v2/silences", + data=json.dumps( + { + "comment": "a testcomment", + "createdBy": "me", + "startsAt": "2029-07-29T08:45:45.000Z", + "endsAt": "2029-07-29T08:55:45.000Z", + "matchers": [ + { + "isEqual": True, + "isRegex": True, + "name": "environment", + "value": "test", + } + ], + }, + sort_keys=True, + ), + headers={ + "Content-Type": "application/json", + "Authorization": self.authorization, + }, + method="POST", + ) + self.assertEquals(result, {"silenceID": "470b7116-8f06-4bb6-9e6c-6258aa92218e"}) + + @patch( + "ansible_collections.community.grafana.plugins.modules.grafana_silence.GrafanaSilenceInterface.get_version" + ) + @patch( + "ansible_collections.community.grafana.plugins.modules.grafana_silence.fetch_url" + ) + def test_delete_silence(self, mock_fetch_url, mock_get_version): + set_module_args( + { + "url": "https://grafana.example.com", + "url_username": "admin", + "url_password": "changeme", + "comment": "a testcomment", + "created_by": "me", + "ends_at": "2029-07-29T08:55:45.000Z", + "matchers": [ + { + "isEqual": True, + "isRegex": True, + "name": "environment", + "value": "test", + } + ], + "starts_at": "2029-07-29T08:45:45.000Z", + "state": "present", + } + ) + module = grafana_silence.setup_module_object() + mock_fetch_url.return_value = silence_deleted_resp() + mock_get_version.return_value = get_version_resp() + + grafana_iface = grafana_silence.GrafanaSilenceInterface(module) + silence_id = "470b7116-8f06-4bb6-9e6c-6258aa92218e" + result = grafana_iface.delete_silence(silence_id) + mock_fetch_url.assert_called_once_with( + module, + "https://grafana.example.com/api/alertmanager/grafana/api/v2/silence/470b7116-8f06-4bb6-9e6c-6258aa92218e", + data=None, + headers={ + "Content-Type": "application/json", + "Authorization": self.authorization, + }, + method="DELETE", + ) + self.assertEquals(result, {"message": "silence deleted"})