Skip to content

Added ND 4.1 version support to the nd_backup and nd_backup_restore module (DCNE-452) #141

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions plugins/httpapi/nd.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def __init__(self, *args, **kwargs):
self.path = ""
self.status = -1
self.info = {}
self.version = None

def get_platform(self):
return self.platform
Expand All @@ -93,7 +94,14 @@ def set_backup_hosts(self):
# TODO Add support for dynamically returning platform versions.
def get_version(self, platform="ndfc"):
if platform == "ndfc":
return 12
if self.version is None:
self.version = 12
return self.version
elif platform == "nd":
if self.version is None:
response_json = self._send_nd_request("GET", "/version.json", self.headers)
self.version = ".".join(str(response_json.get("body")[key]) for key in ["major", "minor", "maintenance"])
return self.version
else:
raise ValueError("Unknown platform type: {0}".format(platform))

Expand Down Expand Up @@ -326,11 +334,14 @@ def send_file_request(self, method, path, file=None, data=None, remote_path=None
raise ConnectionError(json.dumps(self._verify_response(None, method, path, None)))

try:
fields = None
# create fields for MultipartEncoder
if remote_path:
fields = dict(rdir=remote_path, name=(filename, open(file, "rb"), mimetypes.guess_type(filename)))
elif file_key == "importfile":
fields = dict(spec=(json.dumps(data)), importfile=(filename, open(file, "rb"), mimetypes.guess_type(filename)))
elif file_key in ["importfile", "files"]:
fields = {file_key: (filename, open(file, "rb"), mimetypes.guess_type(filename))}
if file_key == "importfile":
fields["spec"] = json.dumps(data)
else:
fields = dict(data=("data.json", data_str, "application/json"), file=(filename, open(file, "rb"), mimetypes.guess_type(filename)))

Expand All @@ -342,6 +353,7 @@ def send_file_request(self, method, path, file=None, data=None, remote_path=None

mp_encoder = MultipartEncoder(fields=fields)
multiheader = {"Content-Type": mp_encoder.content_type, "Accept": "*/*", "Accept-Encoding": "gzip, deflate, br"}
self.connection.queue_message("info", "send_file_request() - connection.send({0}, {1}, {2}, {3})".format(path, method, fields, multiheader))
response, rdata = self.connection.send(path, mp_encoder.to_string(), method=method, headers=multiheader)
except Exception as e:
self.error = dict(code=self.status, message="ND HTTPAPI MultipartEncoder Exception: {0} - {1} ".format(e, traceback.format_exc()))
Expand Down
27 changes: 21 additions & 6 deletions plugins/module_utils/nd.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,28 @@ def __init__(self, module):
self.status = None
self.url = None
self.httpapi_logs = list()
self.connection = None
self.version = None

# Set Connection plugin
self.set_connection()

# Set ND version
self.set_version()

if self.module._debug:
self.module.warn("Enable debug output because ANSIBLE_DEBUG was set.")
self.params["output_level"] = "debug"

def set_version(self):
if self.version is None:
self.version = self.connection.get_version("nd")

def set_connection(self):
if self.connection is None:
self.connection = Connection(self.module._socket_path)
self.connection.set_params(self.params)

def request(
self, path, method=None, data=None, file=None, qs=None, prefix="", file_key="file", output_format="json", ignore_not_found_error=False, file_ext=None
):
Expand All @@ -241,25 +258,23 @@ def request(
if method == "PATCH" and not data:
return {}

conn = Connection(self.module._socket_path)
conn.set_params(self.params)
uri = self.path
if prefix != "":
uri = "{0}/{1}".format(prefix, self.path)
if qs is not None:
uri = uri + update_qs(qs)
try:
if file is not None:
info = conn.send_file_request(method, uri, file, data, None, file_key, file_ext)
info = self.connection.send_file_request(method, uri, file, data, None, file_key, file_ext)
else:
if data:
info = conn.send_request(method, uri, json.dumps(data))
info = self.connection.send_request(method, uri, json.dumps(data))
else:
info = conn.send_request(method, uri)
info = self.connection.send_request(method, uri)
self.result["data"] = data

self.url = info.get("url")
self.httpapi_logs.extend(conn.pop_messages())
self.httpapi_logs.extend(self.connection.pop_messages())
info.pop("date", None)
except Exception as e:
try:
Expand Down
26 changes: 26 additions & 0 deletions plugins/module_utils/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-

# Copyright: (c) 2025, Sabari Jaganathan (@sajagana) <[email protected]>
# 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
135 changes: 126 additions & 9 deletions plugins/modules/nd_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# -*- coding: utf-8 -*-

# Copyright: (c) 2023, Shreyas Srish (@shrsr) <[email protected]>
# Copyright: (c) 2025, Sabari Jaganathan (@sajagana) <[email protected]>
# 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
Expand All @@ -19,6 +20,7 @@
- Manages backup of the cluster configuration.
author:
- Shreyas Srish (@shrsr)
- Sabari Jaganathan (@sajagana)
options:
name:
description:
Expand All @@ -28,38 +30,84 @@
encryption_key:
description:
- The encryption_key for a backup file.
- A minimum of 8 alphanumeric characters is required.
type: str
file_location:
description:
- The download path and file name for a backup.
- When O(file_location) is specified, the backup will be created and automatically downloaded to the local machine at the designated path.
type: str
backup_key:
description:
- The key generated by ND during creation of a backup.
- This key is required when querying or deleting a backup among multiple backups that have the same name.
- This key can be obtained by querying the backup.
- This parameter is not supported on ND v3.2.1 and later.
type: str
remote_location:
description:
- The name of the remote storage location. This parameter is only supported on ND v3.2.1 and later.
- If the O(remote_location) parameter is not specified or O(remote_location="") during backup creation, a local backup will be created.
type: str
backup_type:
description:
- This parameter is only supported on ND v3.2.1 and later.
- 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(backup) for creating a backup of the cluster config.
- Use C(query) for listing all the backed up files.
- Use C(absent) for deleting a backup job.
- Use O(state=backup) for creating and downloading a backup of the cluster config for the ND versions < 3.2.1.
- Use O(state=backup) to create a cluster configuration backup. Automatic download is not supported for the ND versions >= 3.2.1.
- After creation, use O(state=download) to download the backup file.
- Use O(state=download) downloading a backup to the local machine, the O(state=download) is only supported on ND v3.2.1 and later.
- Use O(state=query) for listing all the backed up files.
- Use O(state=absent) for deleting a backup job.
type: str
choices: [ backup, query, absent ]
choices: [ backup, download, query, absent ]
default: backup
extends_documentation_fragment:
- cisco.nd.modules
- cisco.nd.check_mode
"""

EXAMPLES = r"""
- name: Create a Backup
- name: Create a backup for ND versions < 3.2.1
cisco.nd.nd_backup:
name: nexus
encryption_key: testtest
file_location: ./nexus.tgz
state: backup

- name: Create a remote backup for ND versions >= 3.2.1
cisco.nd.nd_backup:
name: nexus
encryption_key: testtest1
remote_location: remote_machine
state: backup

- name: Create a local backup for ND versions >= 3.2.1
cisco.nd.nd_backup:
name: nexus
encryption_key: testtest1
state: backup

- name: Create a backup and download it to the local machine for ND versions >= 3.2.1
cisco.nd.nd_backup:
name: nexus
file_location: ./nexus.tgz
encryption_key: testtest1
state: backup

- name: Download a local/remote backup for ND versions >= 3.2.1
cisco.nd.nd_backup:
name: nexus
state: download
file_location: ./nexus.tgz

- name: Query a Backup job
cisco.nd.nd_backup:
name: nexus
Expand All @@ -83,6 +131,7 @@
from ansible.module_utils._text import to_bytes
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule, nd_argument_spec, write_file
from ansible_collections.cisco.nd.plugins.module_utils.utils import snake_to_camel


def main():
Expand All @@ -92,15 +141,18 @@ def main():
encryption_key=dict(type="str", no_log=False),
file_location=dict(type="str"),
backup_key=dict(type="str", no_log=False),
state=dict(type="str", default="backup", choices=["backup", "query", "absent"]),
remote_location=dict(type="str"),
backup_type=dict(type="str", default="config_only", choices=["config_only", "full"], aliases=["type"]),
state=dict(type="str", default="backup", choices=["backup", "download", "query", "absent"]),
)

module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_if=[
["state", "backup", ["name", "encryption_key", "file_location"]],
["state", "backup", ["name", "encryption_key"]],
["state", "absent", ["name"]],
["state", "download", ["name", "file_location"]],
],
)

Expand All @@ -110,8 +162,75 @@ def main():
encryption_key = nd.params.get("encryption_key")
backup_key = nd.params.get("backup_key")
file_location = nd.params.get("file_location")
remote_location = nd.params.get("remote_location")
backup_type = nd.params.get("backup_type")
state = nd.params.get("state")

if nd.version < "3.2.1":
if not file_location and state in ["backup", "download"]:
nd.fail_json("Parameter 'file_location' is required when state is 'backup|download' for ND versions < 3.2.1.")
nd_backup_before_3_2_1(module, nd, name, encryption_key, file_location, backup_key, state)
elif nd.version >= "3.2.1":
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not else here?

nd_backup_from_3_2_1(module, nd, name, encryption_key, file_location, remote_location, backup_type, state)

nd.exit_json()


def nd_backup_from_3_2_1(module, nd, name, encryption_key, file_location, remote_location, backup_type, state):
if encryption_key is not None:
if len(encryption_key) < 8:
nd.fail_json("Please provide a minimum of 8 alphanumeric characters for the encryption key.")
elif not (any(char.isalpha() for char in encryption_key) and any(char.isdigit() for char in encryption_key) and encryption_key.isalnum()):
nd.fail_json("The encryption_key must contain at least one letter and one number, and have a minimum length of 8 characters.")
Comment on lines +181 to +184
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is ND not failing on these errors? I thought the agreement was made before to do the least amount of validation and let ND API handle it


path = "/api/v1/infra/backups"
backups = nd.query_obj(path)
if name and backups:
for backup in backups.get("backups", []):
if backup.get("name") == name:
nd.existing = backup
break
else:
nd.existing = backups.get("backups", [])

if state == "absent" and nd.existing:
nd.previous = nd.existing
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it intended to do this here instead of already in the querying part?

if not module.check_mode:
nd.request("{0}/{1}".format(path, name), method="DELETE")
nd.existing = {}

elif state == "backup":
if not nd.existing:
payload = {
"name": name,
"type": snake_to_camel(backup_type),
"destination": remote_location if remote_location else "",
"encryptionKey": encryption_key,
}
nd.sanitize(payload, collate=True)

if not module.check_mode:
# Creates backup file and returns None
nd.request(path, method="POST", data=payload)

# Fetching the backup object details to set module current value
nd.existing = nd.request("{0}/{1}".format(path, name), method="GET")

if file_location:
response = nd.request("{0}/{1}/actions/download".format(path, name), method="GET", data=None, output_format="raw")
write_file(module, file_location, to_bytes(response))
elif module.check_mode:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also if nd.previous = nd.existing was set during query then elif state == "backup": could be combined with and not nd.existing: like you do for the download case

nd.existing = nd.proposed
else:
nd.previous = nd.existing

elif state == "download" and file_location and nd.existing:
if not module.check_mode:
Comment on lines +227 to +228
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not combine this when you are already making a complicated elif

response = nd.request("{0}/{1}/actions/download".format(path, name), method="GET", data=None, output_format="raw")
write_file(module, file_location, to_bytes(response))


def nd_backup_before_3_2_1(module, nd, name, encryption_key, file_location, backup_key, state):
if encryption_key is not None and len(encryption_key) < 8:
nd.fail_json("Please provide a minimum of 8 characters for the encryption key.")

Expand Down Expand Up @@ -158,8 +277,6 @@ def main():
write_file(module, file_location, to_bytes(response))
nd.existing = nd.proposed

nd.exit_json()


if __name__ == "__main__":
main()
Loading
Loading