diff --git a/plugins/module_utils/nd.py b/plugins/module_utils/nd.py index cca3ed42..d2a89542 100644 --- a/plugins/module_utils/nd.py +++ b/plugins/module_utils/nd.py @@ -536,3 +536,34 @@ def get_diff(self, unwanted=None): def set_to_empty_string_when_none(self, val): return val if val is not None else "" + + def get_object_by_nested_key_value(self, path, nested_key_path, value, data_key=None): + + response_data = self.request(path, method="GET") + + if not response_data: + return None + + object_list = [] + if isinstance(response_data, list): + object_list = response_data + elif data_key and data_key in response_data: + object_list = response_data.get(data_key) + else: + return None + + keys = nested_key_path.split(".") + + for obj in object_list: + current_level = obj + for key in keys: + if isinstance(current_level, dict): + current_level = current_level.get(key) + else: + current_level = None + break + + if current_level == value: + return obj + + return None diff --git a/plugins/module_utils/utils.py b/plugins/module_utils/utils.py new file mode 100644 index 00000000..01b25163 --- /dev/null +++ b/plugins/module_utils/utils.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Sabari Jaganathan (@sajagana) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +def snake_to_camel(snake_str, upper_case_components=None): + if snake_str is not None and "_" in snake_str: + if upper_case_components is None: + upper_case_components = [] + components = snake_str.split("_") + camel_case_str = components[0] + + for component in components[1:]: + if component in upper_case_components: + camel_case_str += component.upper() + else: + camel_case_str += component.title() + + return camel_case_str + else: + return snake_str diff --git a/plugins/modules/nd_backup_schedule.py b/plugins/modules/nd_backup_schedule.py new file mode 100644 index 00000000..bf74fd74 --- /dev/null +++ b/plugins/modules/nd_backup_schedule.py @@ -0,0 +1,209 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Sabari Jaganathan (@sajagana) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_backup_schedule +version_added: "0.5.0" +short_description: Manages backup schedules on Cisco Nexus Dashboard. +description: +- Manage backup schedules on Cisco Nexus Dashboard. +- This module is only supported on ND v4.1 and later. +author: +- Sabari Jaganathan (@sajagana) +options: + name: + description: + - The name of the backup schedule. + type: str + encryption_key: + description: + - The encryption key for a backup file. + type: str + remote_location: + description: + - The name of the remote storage location. + type: str + frequency: + description: + - The frequency at which remote backups are scheduled to occur at specified intervals on selected days. + type: int + scheduler_date: + description: + - The start date for the backup schedule in the format O(scheduler_date="YYYY-MM-DD"). + type: str + aliases: [ scheduler_start_date, start_date, date ] + scheduler_time: + description: + - The start time for the backup schedule in the format O(scheduler_date="HH-MM-SS"). + type: str + aliases: [ scheduler_start_time, start_time, time ] + backup_type: + description: + - This parameter specifies the kind of snapshot created for the Nexus Dashboard. + - The O(backup_type=config_only) option creates a snapshot that specifically captures the configuration settings of the Nexus Dashboard. + - The O(backup_type=full) option creates a complete snapshot of the entire Nexus Dashboard. + type: str + choices: [ config_only, full ] + default: config_only + aliases: [ type ] + state: + description: + - Use C(present) for creating a backup schedule. + - Use C(query) for listing the backup schedule. + - Use C(absent) for deleting a backup schedule. + type: str + choices: [ present, query, absent ] + default: present +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +""" + +EXAMPLES = r""" +- name: Create a backup schedule + cisco.nd.nd_backup_schedule: + name: backupschedule1 + encryption_key: testtest1 + frequency: 7 + scheduler_date: "2025-01-02" + scheduler_time: "15:04:05" + remote_location: test + state: present + +- name: Update a backup schedule + cisco.nd.nd_backup_schedule: + name: backupschedule1 + encryption_key: testtest2 + frequency: 30 + remote_location: test + state: present + +- name: Query a backup schedule + cisco.nd.nd_backup_schedule: + name: backupschedule1 + state: query + register: query_one + +- name: Query all backup schedules + cisco.nd.nd_backup_schedule: + state: query + register: query_all + +- name: Delete a backup schedule + cisco.nd.nd_backup_schedule: + name: backupschedule1 + state: absent +""" + +RETURN = r""" +""" + + +import datetime +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule, nd_argument_spec +from ansible_collections.cisco.nd.plugins.module_utils.utils import snake_to_camel + + +def main(): + argument_spec = nd_argument_spec() + argument_spec.update( + name=dict(type="str"), + encryption_key=dict(type="str", no_log=True), + remote_location=dict(type="str"), + frequency=dict(type="int"), + scheduler_date=dict(type="str", aliases=["scheduler_start_date", "start_date", "date"]), + scheduler_time=dict(type="str", aliases=["scheduler_start_time", "start_time", "time"]), + backup_type=dict(type="str", default="config_only", choices=["config_only", "full"], aliases=["type"]), + state=dict(type="str", default="present", choices=["present", "query", "absent"]), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ["state", "backup", ["name", "encryption_key"]], + ["state", "absent", ["name"]], + ], + ) + + nd = NDModule(module) + + name = nd.params.get("name") + encryption_key = nd.params.get("encryption_key") + remote_location = nd.params.get("remote_location") + frequency = nd.params.get("frequency") + scheduler_date = nd.params.get("scheduler_date") + scheduler_time = nd.params.get("scheduler_time") + backup_type = snake_to_camel(nd.params.get("backup_type")) + state = nd.params.get("state") + + start_date_time = None + if scheduler_date and scheduler_time: + date_object = datetime.datetime.strptime(scheduler_date, "%Y-%m-%d") + time_object = datetime.datetime.strptime(scheduler_time, "%H:%M:%S") + start_date_time = "{:04d}-{:02d}-{:02d}T{:02d}:{:02d}:{:02d}Z".format( + date_object.year, date_object.month, date_object.day, time_object.hour, time_object.minute, time_object.second + ) + + path = "/api/v1/infra/backups/schedules" + + # Query a specific backup schedule + nd.existing = nd.get_object_by_nested_key_value(path, "name", name, data_key="schedules") + if state != "query": + if nd.existing: + nd.previous = nd.existing + path = "{0}/{1}".format(path, name) + elif not name: + # Query all backup schedules + schedules = nd.request(path, method="GET") + if schedules: + nd.existing = schedules.get("schedules") + + if state == "present": + payload = { + "encryptionKey": encryption_key, + "name": name, + "type": backup_type, + "frequency": frequency, + "remoteLocation": remote_location, + "startTime": start_date_time, + } + + if nd.existing and nd.existing.get("name") == name: + payload["frequency"] = frequency or nd.existing.get("frequency") + payload["remoteLocation"] = remote_location or nd.existing.get("remoteLocation") + payload["startTime"] = start_date_time or nd.existing.get("startTime") + + nd.sanitize(payload, collate=True) + + if not module.check_mode: + if nd.existing and nd.existing.get("name") == name: + nd.request(path, method="PUT", data=payload) + else: + nd.request(path, method="POST", data=payload) + path = "{0}/{1}".format(path, name) + nd.existing = nd.request(path, method="GET") + else: + nd.existing = payload + + elif state == "absent": + if not module.check_mode and nd.existing and nd.existing.get("name") == name: + nd.request(path, method="DELETE") + nd.existing = {} + + nd.exit_json() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_backup_schedule/tasks/main.yml b/tests/integration/targets/nd_backup_schedule/tasks/main.yml new file mode 100644 index 00000000..089a8dee --- /dev/null +++ b/tests/integration/targets/nd_backup_schedule/tasks/main.yml @@ -0,0 +1,284 @@ +# Test code for the ND modules +# Copyright: (c) 2025, Sabari Jaganathan (@sajagana) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +# CLEAN TEST ENVIRONMENT +- name: Query all backup schedules + cisco.nd.nd_backup_schedule: + output_level: debug + state: query + register: query_all + +- name: Delete all backup schedules + cisco.nd.nd_backup_schedule: + output_level: debug + name: "{{ item.name }}" + state: absent + loop: "{{ query_all.current | list }}" + +# CREATE +- name: Create a backup schedule 1 (check_mode) + cisco.nd.nd_backup_schedule: &cm_add_backup_schedule + output_level: debug + name: backupschedule1 + encryption_key: testtest1 + frequency: 1 + scheduler_date: "2025-01-02" + scheduler_time: "15:04:05" + remote_location: test + backup_type: config_only + state: present + check_mode: true + register: cm_add_backup_schedule + +- name: Create a backup schedule 1 + cisco.nd.nd_backup_schedule: + <<: *cm_add_backup_schedule + register: add_backup_schedule + +- name: Create a backup schedule 1 again + cisco.nd.nd_backup_schedule: + <<: *cm_add_backup_schedule + register: add_backup_schedule_again + +- name: Create a backup schedule 2 + cisco.nd.nd_backup_schedule: + <<: *cm_add_backup_schedule + name: backupschedule2 + frequency: 30 + scheduler_date: "2025-01-03" + scheduler_time: "19:04:05" + backup_type: full + register: add_backup_schedule_2 + +- name: Assertion check for create a backup schedules + ansible.builtin.assert: + that: + - cm_add_backup_schedule is changed + - cm_add_backup_schedule.current.encryptionKey == "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER" + - cm_add_backup_schedule.current.frequency == 1 + - cm_add_backup_schedule.current.name == "backupschedule1" + - cm_add_backup_schedule.current.remoteLocation == "test" + - cm_add_backup_schedule.current.startTime == "2025-01-02T15:04:05Z" + - cm_add_backup_schedule.current.type == "configOnly" + - cm_add_backup_schedule.previous == {} + - cm_add_backup_schedule.proposed.encryptionKey == "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER" + - cm_add_backup_schedule.proposed.frequency == 1 + - cm_add_backup_schedule.proposed.name == "backupschedule1" + - cm_add_backup_schedule.proposed.remoteLocation == "test" + - cm_add_backup_schedule.proposed.startTime == "2025-01-02T15:04:05Z" + - cm_add_backup_schedule.proposed.type == "configOnly" + - add_backup_schedule is changed + - add_backup_schedule.current.frequency == 1 + - add_backup_schedule.current.name == "backupschedule1" + - add_backup_schedule.current.remoteLocation == "test" + - add_backup_schedule.current.startTime == "2025-01-02T15:04:05Z" + - add_backup_schedule.current.type == "configOnly" + - add_backup_schedule.current.user == "admin" + - add_backup_schedule.previous == {} + - add_backup_schedule_again is not changed + - add_backup_schedule_again.current.frequency == 1 + - add_backup_schedule_again.current.name == "backupschedule1" + - add_backup_schedule_again.current.remoteLocation == "test" + - add_backup_schedule_again.current.startTime == "2025-01-02T15:04:05Z" + - add_backup_schedule_again.current.type == "configOnly" + - add_backup_schedule_again.current.user == "admin" + - add_backup_schedule_again.current == add_backup_schedule_again.previous + - add_backup_schedule_2 is changed + - add_backup_schedule_2.current.frequency == 30 + - add_backup_schedule_2.current.name == "backupschedule2" + - add_backup_schedule_2.current.remoteLocation == "test" + - add_backup_schedule_2.current.startTime == "2025-01-03T19:04:05Z" + - add_backup_schedule_2.current.type == "full" + - add_backup_schedule_2.current.user == "admin" + - add_backup_schedule_2.previous == {} + +# UPDATE +- name: Update a backup schedule 1 (check_mode) + cisco.nd.nd_backup_schedule: &cm_update_backup_schedule + output_level: debug + name: backupschedule1 + encryption_key: testtest1 + frequency: 7 + remote_location: test + state: present + check_mode: true + register: cm_update_backup_schedule + +- name: Update a backup schedule 1 + cisco.nd.nd_backup_schedule: + <<: *cm_update_backup_schedule + register: update_backup_schedule + +- name: Update a backup schedule 1 again + cisco.nd.nd_backup_schedule: + <<: *cm_update_backup_schedule + register: update_backup_schedule_again + +- name: Assertion check for update a backup schedule 1 (check_mode) + ansible.builtin.assert: + that: + - cm_update_backup_schedule is changed + - cm_update_backup_schedule.current.encryptionKey == "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER" + - cm_update_backup_schedule.current.frequency == 7 + - cm_update_backup_schedule.current.name == "backupschedule1" + - cm_update_backup_schedule.current.remoteLocation == "test" + - cm_update_backup_schedule.current.startTime == "2025-01-02T15:04:05Z" + - cm_update_backup_schedule.current.type == "configOnly" + - cm_update_backup_schedule.previous.frequency == 1 + - cm_update_backup_schedule.previous.name == "backupschedule1" + - cm_update_backup_schedule.previous.remoteLocation == "test" + - cm_update_backup_schedule.previous.startTime == "2025-01-02T15:04:05Z" + - cm_update_backup_schedule.previous.type == "configOnly" + - cm_update_backup_schedule.previous.user == "admin" + - cm_update_backup_schedule.proposed.encryptionKey == "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER" + - cm_update_backup_schedule.proposed.frequency == 7 + - cm_update_backup_schedule.proposed.name == "backupschedule1" + - cm_update_backup_schedule.proposed.remoteLocation == "test" + - cm_update_backup_schedule.proposed.startTime == "2025-01-02T15:04:05Z" + - cm_update_backup_schedule.proposed.type == "configOnly" + - cm_update_backup_schedule.proposed.user == "admin" + - update_backup_schedule is changed + - update_backup_schedule.current.frequency == 7 + - update_backup_schedule.current.name == "backupschedule1" + - update_backup_schedule.current.remoteLocation == "test" + - update_backup_schedule.current.startTime == "2025-01-02T15:04:05Z" + - update_backup_schedule.current.type == "configOnly" + - update_backup_schedule.current.user == "admin" + - update_backup_schedule.previous.frequency == 1 + - update_backup_schedule.previous.name == "backupschedule1" + - update_backup_schedule.previous.remoteLocation == "test" + - update_backup_schedule.previous.startTime == "2025-01-02T15:04:05Z" + - update_backup_schedule.previous.type == "configOnly" + - update_backup_schedule_again is not changed + - update_backup_schedule_again.current.frequency == 7 + - update_backup_schedule_again.current.name == "backupschedule1" + - update_backup_schedule_again.current.remoteLocation == "test" + - update_backup_schedule_again.current.startTime == "2025-01-02T15:04:05Z" + - update_backup_schedule_again.current.type == "configOnly" + - update_backup_schedule_again.current == update_backup_schedule_again.previous + +# ERROR +- name: Negative test create a backup schedule 3 + cisco.nd.nd_backup_schedule: &add_nt_backup_schedule + output_level: debug + name: backupschedule3 + encryption_key: testtest1 + frequency: 30 + scheduler_date: "2025-01-03" + scheduler_time: "19:04:05" + remote_location: test + state: present + register: nt_add_backup_schedule_3 + ignore_errors: true + +- name: Negative test create a backup schedule 4 with invalid name + cisco.nd.nd_backup_schedule: + <<: *add_nt_backup_schedule + name: BackupSchedule4 + register: nt_add_backup_schedule_4 + ignore_errors: true + +- name: Negative test create a backup schedule 5 with invalid encryption key + cisco.nd.nd_backup_schedule: + <<: *add_nt_backup_schedule + name: backupschedule5 + encryption_key: testtest + register: nt_add_backup_schedule_5 + ignore_errors: true + +- name: Assertion check for negative test create a backup schedule 3 + ansible.builtin.assert: + that: + - nt_add_backup_schedule_3 is failed + - nt_add_backup_schedule_3.msg == "ND Error 400{{':'}} The maximum number of backup schedules is 2" + - nt_add_backup_schedule_4 is failed + - nt_add_backup_schedule_4.msg == "ND Error 400{{':'}} Name must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character, with a maximum length of 63 characters. '-' and '.' must follow an alphanumeric character." + - nt_add_backup_schedule_5 is failed + - nt_add_backup_schedule_5.msg == "ND Error 400{{':'}} field 'encryptionKey' is invalid. It must contain at least one letter and one number, and have a minimum length of 8 characters" + +# QUERY +- name: Query a backup schedule 1 + cisco.nd.nd_backup_schedule: + output_level: debug + name: backupschedule1 + state: query + register: query_one + +- name: Query all backup schedules + cisco.nd.nd_backup_schedule: + output_level: debug + state: query + register: query_all_1 + +- name: Assertion check for query a backup schedule + ansible.builtin.assert: + that: + - query_one is not changed + - query_one.current.frequency == 7 + - query_one.current.name == "backupschedule1" + - query_one.current.remoteLocation == "test" + - query_one.current.startTime == "2025-01-02T15:04:05Z" + - query_one.current.type == "configOnly" + - query_one.current.user == "admin" + - query_all_1 is not changed + - query_all_1.current.0.frequency == 7 + - query_all_1.current.0.name == "backupschedule1" + - query_all_1.current.0.remoteLocation == "test" + - query_all_1.current.0.startTime == "2025-01-02T15:04:05Z" + - query_all_1.current.0.type == "configOnly" + - query_all_1.current.0.user == "admin" + - query_all_1.current.1.frequency == 30 + - query_all_1.current.1.name == "backupschedule2" + - query_all_1.current.1.remoteLocation == "test" + - query_all_1.current.1.startTime == "2025-01-03T19:04:05Z" + - query_all_1.current.1.type == "full" + - query_all_1.current.1.user == "admin" + +# DELETE +- name: Delete a backup schedule 1 (check_mode) + cisco.nd.nd_backup_schedule: &cm_rm_backup_schedule + output_level: debug + name: backupschedule1 + state: absent + check_mode: true + register: cm_rm_backup_schedule + +- name: Delete a backup schedule 1 + cisco.nd.nd_backup_schedule: + <<: *cm_rm_backup_schedule + register: rm_backup_schedule + +- name: Delete a backup schedule 1 again + cisco.nd.nd_backup_schedule: + <<: *cm_rm_backup_schedule + register: rm_backup_schedule_again + +- name: Delete a backup schedule 2 + cisco.nd.nd_backup_schedule: + <<: *cm_rm_backup_schedule + name: backupschedule2 + +- name: Assertion check for delete a backup schedule + ansible.builtin.assert: + that: + - cm_rm_backup_schedule is changed + - cm_rm_backup_schedule.current == {} + - cm_rm_backup_schedule.previous.frequency == 7 + - cm_rm_backup_schedule.previous.name == "backupschedule1" + - cm_rm_backup_schedule.previous.remoteLocation == "test" + - cm_rm_backup_schedule.previous.startTime == "2025-01-02T15:04:05Z" + - cm_rm_backup_schedule.previous.type == "configOnly" + - cm_rm_backup_schedule.previous.user == "admin" + - rm_backup_schedule is changed + - rm_backup_schedule.current == {} + - rm_backup_schedule.previous.frequency == 7 + - rm_backup_schedule.previous.name == "backupschedule1" + - rm_backup_schedule.previous.remoteLocation == "test" + - rm_backup_schedule.previous.startTime == "2025-01-02T15:04:05Z" + - rm_backup_schedule.previous.type == "configOnly" + - rm_backup_schedule.previous.user == "admin" + - rm_backup_schedule_again is not changed + - rm_backup_schedule_again.current == {} + - rm_backup_schedule_again.previous == {}