From cbebc86cfe4143cf405da1b3fb732b3bf70c0a73 Mon Sep 17 00:00:00 2001 From: "max.sickora" Date: Fri, 25 Aug 2023 11:17:08 +0200 Subject: [PATCH] New timeperiod module --- changelogs/fragments/timeperiod.yml | 2 + plugins/modules/timeperiod.py | 412 ++++++++++++++++++ .../targets/timeperiod/tasks/main.yml | 18 + .../targets/timeperiod/tasks/prep.yml | 31 ++ .../targets/timeperiod/tasks/test.yml | 79 ++++ .../targets/timeperiod/vars/main.yml | 59 +++ 6 files changed, 601 insertions(+) create mode 100644 changelogs/fragments/timeperiod.yml create mode 100644 plugins/modules/timeperiod.py create mode 100644 tests/integration/targets/timeperiod/tasks/main.yml create mode 100644 tests/integration/targets/timeperiod/tasks/prep.yml create mode 100644 tests/integration/targets/timeperiod/tasks/test.yml create mode 100644 tests/integration/targets/timeperiod/vars/main.yml diff --git a/changelogs/fragments/timeperiod.yml b/changelogs/fragments/timeperiod.yml new file mode 100644 index 000000000..5ed011f17 --- /dev/null +++ b/changelogs/fragments/timeperiod.yml @@ -0,0 +1,2 @@ +major_changes: + - Timeperiod module - Add timeperiod module. \ No newline at end of file diff --git a/plugins/modules/timeperiod.py b/plugins/modules/timeperiod.py new file mode 100644 index 000000000..5db2a162e --- /dev/null +++ b/plugins/modules/timeperiod.py @@ -0,0 +1,412 @@ +#!/usr/bin/python +# -*- encoding: utf-8; py-indent-offset: 4 -*- + +# Copyright: (c) 2023, Max Sickora +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" +--- +module: timeperiod + +short_description: Manage time periods in checkmk. + +# If this is part of a collection, you need to use semantic versioning, +# i.e. the version is of the form "2.5.0" and not "2.4". +version_added: "3.3.0" + +description: +- Manage time periods in checkmk. + +extends_documentation_fragment: [checkmk.general.common] + +options: + name: + description: An unique identifier for the time period. + required: true + type: str + + alias: + description: An alias for the time period. + required: false + type: str + + active_time_ranges: + description: The list of active time ranges. + required: false + type: raw + + exceptions: + description: A list of additional time ranges to be added. + required: false + type: raw + + exclude: + description: A list of time period aliases whose periods are excluded. + required: false + type: raw + + state: + description: create/update or delete a time period. + required: true + choices: ["present", "absent"] + type: str + +author: + - Max Sickora (@max-checkmk) +""" + +EXAMPLES = r""" +# Creating and Updating is the same. +- name: "Create a new time period." + checkmk.general.timeperiod: + server_url: "http://localhost/" + site: "my_site" + automation_user: "automation" + automation_secret: "$SECRET" + name: "worktime" + title: "Work time" + active_time_ranges: [ + exceptions: [ + + ] + ] + exclude: [ + "lunch" + ] + state: "present" + +- name: "Delete a time period." + checkmk.general.timeperiod: + server_url: "http://localhost/" + site: "my_site" + automation_user: "automation" + automation_secret: "$SECRET" + name: "worktime" + state: "absent" +""" + +RETURN = r""" +http_code: + description: The HTTP code the Checkmk API returns. + type: int + returned: always + sample: '200' +message: + description: The output message that the module generates. + type: str + returned: always + sample: 'Done.' +""" + +import time +import json + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.checkmk.general.plugins.module_utils.api import CheckmkAPI +from ansible_collections.checkmk.general.plugins.module_utils.types import RESULT +from ansible_collections.checkmk.general.plugins.module_utils.utils import ( + result_as_dict, +) + +# We count 404 not as failed, because we want to know if the time period exists or not. +HTTP_CODES_GET = { + # http_code: (changed, failed, "Message") + 200: (True, False, "OK: The operation was done successfully."), + 400: (False, True, "Bad Request: Parameter or validation failure."), + 403: (False, True, "Forbidden: Configuration via Setup is disabled."), + 404: (False, False, "Not Found: The requested object has not been found."), + 406: ( + False, + True, + "Not Acceptable: The requests accept headers can not be satisfied.", + ), + 415: ( + False, + True, + "Unsupported Media Type: The submitted content-type is not supported.", + ), + 500: (False, True, "General Server Error."), +} + +HTTP_CODES_DELETE = { + # http_code: (changed, failed, "Message") + 204: (True, False, "No Content: Operation done successfully. No further output."), + 400: (False, True, "Bad Request: Parameter or validation failure."), + 403: (False, True, "Forbidden: Configuration via Setup is disabled."), + 404: (False, True, "Not Found: The requested object has not been found."), + 405: ( + False, + True, + "Method Not Allowed: This request is only allowed with other HTTP methods", + ), + 406: ( + False, + True, + "Not Acceptable: The requests accept headers can not be satisfied.", + ), + 412: ( + False, + True, + "Precondition Failed: The value of the If-Match header doesn't match the object's ETag.", + ), + 415: ( + False, + True, + "Unsupported Media Type: The submitted content-type is not supported.", + ), + 428: ( + False, + True, + "Precondition Required: The required If-Match header is missing", + ), + 500: (False, True, "General Server Error."), +} + +HTTP_CODES_CREATE = { + # http_code: (changed, failed, "Message") + 200: (True, False, "OK: The operation was done successfully."), + 400: (False, True, "Bad Request: Parameter or validation failure."), + 403: (False, True, "Forbidden: Configuration via Setup is disabled."), + 406: ( + False, + True, + "Not Acceptable: The requests accept headers can not be satisfied.", + ), + 415: ( + False, + True, + "Unsupported Media Type: The submitted content-type is not supported.", + ), + 500: (False, True, "General Server Error."), +} + +HTTP_CODES_UPDATE = { + # http_code: (changed, failed, "Message") + 200: ( + True, + False, + "No Content: Operation was done successfully. No further output", + ), + 403: (False, True, "Forbidden: Configuration via Setup is disabled."), + 404: (False, True, "Not Found: The requested object has not been found."), + 405: ( + False, + True, + "Method Not Allowed: This request is only allowed with other HTTP methods", + ), + 406: ( + False, + True, + "Not Acceptable: The requests accept headers can not be satisfied.", + ), + 412: ( + False, + True, + "Precondition Failed: The value of the If-Match header doesn't match the object's ETag.", + ), + 415: ( + False, + True, + "Unsupported Media Type: The submitted content-type is not supported.", + ), + 428: ( + False, + True, + "Precondition Required: The required If-Match header is missing", + ), + 500: (False, True, "General Server Error."), +} + + +class TimeperiodCreateAPI(CheckmkAPI): + def post(self): + data = { + "name": self.params.get("name", ""), + "alias": self.params.get("alias", ""), + "active_time_ranges": self.params.get("active_time_ranges", ""), + } + + if self.params.get("exceptions") is not None: + data["exceptions"] = self.params.get("exceptions") + + if self.params.get("exclude") is not None: + data["exclude"] = self.params.get("exclude") + + return self._fetch( + code_mapping=HTTP_CODES_CREATE, + endpoint="/domain-types/time_period/collections/all", + data=data, + method="POST", + ) + + +class TimeperiodUpdateAPI(CheckmkAPI): + def put(self, existingalias): + data = {} + if ( + self.params.get("alias") is not None + and self.params.get("alias") is not existingalias + ): + data["alias"] = self.params.get("alias") + + if self.params.get("active_time_ranges") is not None: + data["active_time_ranges"] = self.params.get("active_time_ranges") + + if self.params.get("exceptions") is not None: + data["exceptions"] = self.params.get("exceptions") + + if self.params.get("exclude") is not None: + data["exclude"] = self.params.get("exclude") + + return self._fetch( + code_mapping=HTTP_CODES_UPDATE, + endpoint="/objects/time_period/%s" % self.params.get("name"), + data=data, + method="PUT", + ) + + +class TimeperiodDeleteAPI(CheckmkAPI): + def delete(self): + data = {} + + return self._fetch( + code_mapping=HTTP_CODES_DELETE, + endpoint="/objects/time_period/%s" % self.params.get("name"), + data=data, + method="DELETE", + ) + + +class TimeperiodGetAPI(CheckmkAPI): + def get(self): + data = {} + + return self._fetch( + code_mapping=HTTP_CODES_GET, + endpoint="/objects/time_period/%s" % self.params.get("name"), + data=data, + method="GET", + ) + + +def patched_version(checkmkversion): + if ( + checkmkversion[0] == "2" + and checkmkversion[1] == "2" + and int(checkmkversion[2]) >= 9 + ): + return True + if ( + checkmkversion[0] == "2" + and checkmkversion[1] == "1" + and int(checkmkversion[2]) >= 33 + ): + return True + return False + + +def run_module(): + module_args = dict( + server_url=dict(type="str", required=True), + site=dict(type="str", required=True), + validate_certs=dict(type="bool", required=False, default=True), + automation_user=dict(type="str", required=True), + automation_secret=dict(type="str", required=True, no_log=True), + name=dict(type="str", required=True), + alias=dict(type="str", required=False), + active_time_ranges=dict(type="raw", required=False), + exceptions=dict(type="raw", required=False), + exclude=dict(type="raw", required=False), + state=dict( + type="str", + choices=["present", "absent"], + required=True, + ), + ) + + module = AnsibleModule(argument_spec=module_args, supports_check_mode=False) + + result = RESULT( + http_code=0, + msg="Nothing to be done", + content="", + etag="", + failed=False, + changed=False, + ) + + if module.params.get("state") == "present": + timeperiodget = TimeperiodGetAPI(module) + result = timeperiodget.get() + + # Time period exists already - Do an update. + if result.http_code == 200: + checkmkversion = timeperiodget.getversion() + + if module.params.get("exclude") and not patched_version(checkmkversion): + result = RESULT( + http_code=0, + msg="Can't update exclude value in Checkmk 2.2.0p8 or 2.1.0p32 and before. See Werk #16052", + content="", + etag="", + failed=True, + changed=False, + ) + module.fail_json(**result_as_dict(result)) + + timeperiodupdate = TimeperiodUpdateAPI(module) + timeperiodupdate.headers["If-Match"] = result.etag + # Different output of "Show a time period" in Version 2.0 + if checkmkversion[0] == "2" and checkmkversion[1] == "0": + existingalias = json.loads(result.content).get("alias") + else: + existingalias = ( + json.loads(result.content).get("extensions").get("alias") + ) + result = timeperiodupdate.put(existingalias) + + time.sleep(3) + + # Time period doesn't exist - Create new one. + elif result.http_code == 404: + timeperiodcreate = TimeperiodCreateAPI(module) + result = timeperiodcreate.post() + + time.sleep(3) + + if module.params.get("state") == "absent": + timeperiodget = TimeperiodGetAPI(module) + result = timeperiodget.get() + + if result.http_code == 200: + timeperioddelete = TimeperiodDeleteAPI(module) + timeperioddelete.headers["If-Match"] = result.etag + result = timeperioddelete.delete() + + time.sleep(3) + + elif result.http_code == 404: + result = RESULT( + http_code=404, + msg="Time period doesn't exist.", + content="", + etag="", + failed=True, + changed=False, + ) + + time.sleep(3) + + module.exit_json(**result_as_dict(result)) + + +def main(): + run_module() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/timeperiod/tasks/main.yml b/tests/integration/targets/timeperiod/tasks/main.yml new file mode 100644 index 000000000..d2a7115a7 --- /dev/null +++ b/tests/integration/targets/timeperiod/tasks/main.yml @@ -0,0 +1,18 @@ +--- +- name: "Run preparations." + ansible.builtin.include_tasks: prep.yml + +- name: "Wait for site to be ready." + ansible.builtin.pause: + seconds: 5 + when: | + ((download_pass is defined and download_pass | length) or item.item.edition == 'cre') + and (item.stdout_lines is defined and 'OVERALL 1' in item.stdout_lines) + loop: "{{ site_status.results }}" + +- name: "Testing." + ansible.builtin.include_tasks: test.yml + loop: "{{ test_sites }}" + loop_control: + loop_var: outer_item + when: (download_pass is defined and download_pass | length) or outer_item.edition == "cre" diff --git a/tests/integration/targets/timeperiod/tasks/prep.yml b/tests/integration/targets/timeperiod/tasks/prep.yml new file mode 100644 index 000000000..7f81d9051 --- /dev/null +++ b/tests/integration/targets/timeperiod/tasks/prep.yml @@ -0,0 +1,31 @@ +--- +- name: "Download Checkmk Versions." + ansible.builtin.get_url: + url: "{{ download_url }}" + dest: /tmp/checkmk-server-{{ item.site }}.deb + mode: "0640" + url_username: "{{ download_user | default(omit) }}" + url_password: "{{ download_pass | default(omit) }}" + loop: "{{ test_sites }}" + when: (download_pass is defined and download_pass | length) or item.edition == "cre" + +- name: "Install Checkmk Versions." + ansible.builtin.apt: + deb: /tmp/checkmk-server-{{ item.site }}.deb + state: present + loop: "{{ test_sites }}" + when: (download_pass is defined and download_pass | length) or item.edition == "cre" + +- name: "Create Sites." + ansible.builtin.command: "omd -V {{ item.version }}.{{ item.edition }} create --no-tmpfs --admin-password {{ automation_secret }} {{ item.site }}" + args: + creates: "/omd/sites/{{ item.site }}" + loop: "{{ test_sites }}" + when: (download_pass is defined and download_pass | length) or item.edition == "cre" + +- name: "Start Sites." + ansible.builtin.shell: "omd status -b {{ item.site }} || omd start {{ item.site }}" + register: site_status + changed_when: site_status.rc == "0" + loop: "{{ test_sites }}" + when: (download_pass is defined and download_pass | length) or item.edition == "cre" diff --git a/tests/integration/targets/timeperiod/tasks/test.yml b/tests/integration/targets/timeperiod/tasks/test.yml new file mode 100644 index 000000000..15f5815db --- /dev/null +++ b/tests/integration/targets/timeperiod/tasks/test.yml @@ -0,0 +1,79 @@ +--- +- name: "{{ outer_item.version }} - Create new time periods." + timeperiod: + server_url: "{{ server_url }}" + site: "{{ outer_item.site }}" + automation_user: "{{ automation_user }}" + automation_secret: "{{ automation_secret }}" + name: "{{ item.name }}" + alias: "{{ item.alias }}" + active_time_ranges: "{{ item.active_time_ranges }}" + exceptions: "{{ item.exceptions | default(omit) }}" + exclude: "{{ item.exclude | default(omit) }}" + state: "present" + delegate_to: localhost + loop: "{{ checkmk_timeperiods_create }}" + no_log: false + +- name: "{{ outer_item.version }} - Activate." + activation: + server_url: "{{ server_url }}" + site: "{{ outer_item.site }}" + automation_user: "{{ automation_user }}" + automation_secret: "{{ automation_secret }}" + force_foreign_changes: true + sites: + - "{{ outer_item.site }}" + delegate_to: localhost + run_once: true # noqa run-once[task] + +- name: "{{ outer_item.version }} - Update time periods." + timeperiod: + server_url: "{{ server_url }}" + site: "{{ outer_item.site }}" + automation_user: "{{ automation_user }}" + automation_secret: "{{ automation_secret }}" + name: "{{ item.name }}" + alias: "{{ item.alias | default(omit) }}" + active_time_ranges: "{{ item.active_time_ranges | default(omit) }}" + exceptions: "{{ item.exceptions | default(omit) }}" + exclude: "{{ item.exclude | default(omit) }}" + state: "present" + delegate_to: localhost + loop: "{{ checkmk_timeperiods_update }}" + no_log: false + +- name: "{{ outer_item.version }} - Activate." + activation: + server_url: "{{ server_url }}" + site: "{{ outer_item.site }}" + automation_user: "{{ automation_user }}" + automation_secret: "{{ automation_secret }}" + force_foreign_changes: true + sites: + - "{{ outer_item.site }}" + delegate_to: localhost + run_once: true # noqa run-once[task] + +- name: "Delete a time period." + timeperiod: + server_url: "{{ server_url }}" + site: "{{ outer_item.site }}" + automation_user: "{{ automation_user }}" + automation_secret: "{{ automation_secret }}" + name: "{{ item.name }}" + state: "absent" + delegate_to: localhost + loop: "{{ checkmk_timeperiods_delete }}" + +- name: "{{ outer_item.version }} - Activate." + activation: + server_url: "{{ server_url }}" + site: "{{ outer_item.site }}" + automation_user: "{{ automation_user }}" + automation_secret: "{{ automation_secret }}" + force_foreign_changes: true + sites: + - "{{ outer_item.site }}" + delegate_to: localhost + run_once: true # noqa run-once[task] diff --git a/tests/integration/targets/timeperiod/vars/main.yml b/tests/integration/targets/timeperiod/vars/main.yml new file mode 100644 index 000000000..8cefc85da --- /dev/null +++ b/tests/integration/targets/timeperiod/vars/main.yml @@ -0,0 +1,59 @@ +--- +# In these integration tests, we do not normalize the edition naming, but stick +# to the naming of the setup files. For example 'raw' and 'enterprise' rather +# than 'CRE' and 'CEE'. +test_sites: + - version: "2.2.0p8" + edition: "cre" + site: "stable_raw" + - version: "2.2.0p8" + edition: "cee" + site: "stable_ent" + - version: "2.1.0p32" + edition: "cre" + site: "old_raw" + - version: "2.0.0p38" + edition: "cre" + site: "ancient_raw" + +server_url: "http://127.0.0.1/" +automation_user: "cmkadmin" +automation_secret: "d7589df1" + +download_url: "https://download.checkmk.com/checkmk/{{ item.version }}/check-mk-{{ checkmk_server_edition_mapping[item.edition] }}-{{ item.version }}_0.{{ ansible_distribution_release }}_amd64.deb" # noqa yaml[line-length] +download_user: "d-gh-ansible-dl" +download_pass: "{{ lookup('ansible.builtin.file', '/root/ansible_collections/checkmk/general/tests/integration/files/.dl-secret', errors='ignore') | default(omit) }}" # noqa yaml[line-length] + +# Due to inconsistent naming of editions, we normalize them here for convenience +checkmk_server_edition_mapping: + cre: raw + cfe: free + cee: enterprise + cce: cloud + cme: managed + +checkmk_timeperiods_create: + - name: "lunchtime" + alias: "Lunchtime" + active_time_ranges: '[{"day": "all", "time_ranges": [{"start": "12:00:00", "end": "13:00:00"}]}]' + - name: "worktime" + alias: "Worktime" + active_time_ranges: '[{"day": "all", "time_ranges": [{"start": "09:00:00", "end": "17:00:00"}]}]' + exceptions: '[{"date": "2023-12-24", "time_ranges": [{"start": "10:00:00", "end": "12:00:00"}]}]' + exclude: '[ "Lunchtime" ]' + - name: "notonfriday" + alias: "Notonfriday" + active_time_ranges: '[{"day": "friday", "time_ranges": [{"start": "13:00:00", "end": "23:59:59"}]}]' + +checkmk_timeperiods_update: + - name: "worktime" + active_time_ranges: '[{"day": "all", "time_ranges": [{"start": "08:00:00", "end": "17:00:00"}]}]' + exceptions: '[{"date": "2023-12-24", "time_ranges": [{"start": "10:00:00", "end": "12:00:00"}]}]' + - name: "notonfriday" + active_time_ranges: '[{"day": "friday", "time_ranges": [{"start": "00:00:00", "end": "23:59:59"}]}]' + + +checkmk_timeperiods_delete: + - name: "worktime" + - name: "lunchtime" + - name: "notonfriday"