Skip to content

Commit 16b0e02

Browse files
committed
[minor_change] Added ND 4.1 version support to the nd_backup and nd_backup_restore module
1 parent 2dae898 commit 16b0e02

File tree

10 files changed

+1659
-455
lines changed

10 files changed

+1659
-455
lines changed

plugins/httpapi/nd.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def __init__(self, *args, **kwargs):
7272
self.path = ""
7373
self.status = -1
7474
self.info = {}
75+
self.version = None
7576

7677
def get_platform(self):
7778
return self.platform
@@ -93,7 +94,14 @@ def set_backup_hosts(self):
9394
# TODO Add support for dynamically returning platform versions.
9495
def get_version(self, platform="ndfc"):
9596
if platform == "ndfc":
96-
return 12
97+
if self.version is None:
98+
self.version = 12
99+
return self.version
100+
elif platform == "nd":
101+
if self.version is None:
102+
response_json = self._send_nd_request("GET", "/version.json", self.headers)
103+
self.version = ".".join(str(response_json.get("body")[key]) for key in ["major", "minor", "maintenance"])
104+
return self.version
97105
else:
98106
raise ValueError("Unknown platform type: {0}".format(platform))
99107

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

328336
try:
337+
fields = None
329338
# create fields for MultipartEncoder
330339
if remote_path:
331340
fields = dict(rdir=remote_path, name=(filename, open(file, "rb"), mimetypes.guess_type(filename)))
332-
elif file_key == "importfile":
333-
fields = dict(spec=(json.dumps(data)), importfile=(filename, open(file, "rb"), mimetypes.guess_type(filename)))
341+
elif file_key in ["importfile", "files"]:
342+
fields = {file_key: (filename, open(file, "rb"), mimetypes.guess_type(filename))}
343+
if file_key == "importfile":
344+
fields["spec"] = json.dumps(data)
334345
else:
335346
fields = dict(data=("data.json", data_str, "application/json"), file=(filename, open(file, "rb"), mimetypes.guess_type(filename)))
336347

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

343354
mp_encoder = MultipartEncoder(fields=fields)
344355
multiheader = {"Content-Type": mp_encoder.content_type, "Accept": "*/*", "Accept-Encoding": "gzip, deflate, br"}
356+
self.connection.queue_message("info", "send_file_request() - connection.send({0}, {1}, {2}, {3})".format(path, method, fields, multiheader))
345357
response, rdata = self.connection.send(path, mp_encoder.to_string(), method=method, headers=multiheader)
346358
except Exception as e:
347359
self.error = dict(code=self.status, message="ND HTTPAPI MultipartEncoder Exception: {0} - {1} ".format(e, traceback.format_exc()))

plugins/module_utils/nd.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,28 @@ def __init__(self, module):
223223
self.status = None
224224
self.url = None
225225
self.httpapi_logs = list()
226+
self.connection = None
227+
self.version = None
228+
229+
# Set Connection plugin
230+
self.set_connection()
231+
232+
# Set ND version
233+
self.set_version()
226234

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

239+
def set_version(self):
240+
if self.version is None:
241+
self.version = self.connection.get_version("nd")
242+
243+
def set_connection(self):
244+
if self.connection is None:
245+
self.connection = Connection(self.module._socket_path)
246+
self.connection.set_params(self.params)
247+
231248
def request(
232249
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
233250
):
@@ -241,25 +258,23 @@ def request(
241258
if method == "PATCH" and not data:
242259
return {}
243260

244-
conn = Connection(self.module._socket_path)
245-
conn.set_params(self.params)
246261
uri = self.path
247262
if prefix != "":
248263
uri = "{0}/{1}".format(prefix, self.path)
249264
if qs is not None:
250265
uri = uri + update_qs(qs)
251266
try:
252267
if file is not None:
253-
info = conn.send_file_request(method, uri, file, data, None, file_key, file_ext)
268+
info = self.connection.send_file_request(method, uri, file, data, None, file_key, file_ext)
254269
else:
255270
if data:
256-
info = conn.send_request(method, uri, json.dumps(data))
271+
info = self.connection.send_request(method, uri, json.dumps(data))
257272
else:
258-
info = conn.send_request(method, uri)
273+
info = self.connection.send_request(method, uri)
259274
self.result["data"] = data
260275

261276
self.url = info.get("url")
262-
self.httpapi_logs.extend(conn.pop_messages())
277+
self.httpapi_logs.extend(self.connection.pop_messages())
263278
info.pop("date", None)
264279
except Exception as e:
265280
try:

plugins/module_utils/utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# -*- coding: utf-8 -*-
2+
3+
# Copyright: (c) 2025, Sabari Jaganathan (@sajagana) <[email protected]>
4+
# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
5+
6+
from __future__ import absolute_import, division, print_function
7+
8+
__metaclass__ = type
9+
10+
11+
def snake_to_camel(snake_str, upper_case_components=None):
12+
if snake_str is not None and "_" in snake_str:
13+
if upper_case_components is None:
14+
upper_case_components = []
15+
components = snake_str.split("_")
16+
camel_case_str = components[0]
17+
18+
for component in components[1:]:
19+
if component in upper_case_components:
20+
camel_case_str += component.upper()
21+
else:
22+
camel_case_str += component.title()
23+
24+
return camel_case_str
25+
else:
26+
return snake_str

plugins/modules/nd_backup.py

Lines changed: 126 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# -*- coding: utf-8 -*-
33

44
# Copyright: (c) 2023, Shreyas Srish (@shrsr) <[email protected]>
5+
# Copyright: (c) 2025, Sabari Jaganathan (@sajagana) <[email protected]>
56
# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt)
67

78
from __future__ import absolute_import, division, print_function
@@ -19,6 +20,7 @@
1920
- Manages backup of the cluster configuration.
2021
author:
2122
- Shreyas Srish (@shrsr)
23+
- Sabari Jaganathan (@sajagana)
2224
options:
2325
name:
2426
description:
@@ -28,38 +30,84 @@
2830
encryption_key:
2931
description:
3032
- The encryption_key for a backup file.
33+
- A minimum of 8 alphanumeric characters is required.
3134
type: str
3235
file_location:
3336
description:
3437
- The download path and file name for a backup.
38+
- When O(file_location) is specified, the backup will be created and automatically downloaded to the local machine at the designated path.
3539
type: str
3640
backup_key:
3741
description:
3842
- The key generated by ND during creation of a backup.
3943
- This key is required when querying or deleting a backup among multiple backups that have the same name.
4044
- This key can be obtained by querying the backup.
45+
- This parameter is not supported on ND v3.2.1 and later.
4146
type: str
47+
remote_location:
48+
description:
49+
- The name of the remote storage location. This parameter is only supported on ND v3.2.1 and later.
50+
- If the O(remote_location) parameter is not specified or O(remote_location="") during backup creation, a local backup will be created.
51+
type: str
52+
backup_type:
53+
description:
54+
- This parameter is only supported on ND v3.2.1 and later.
55+
- The O(backup_type=config_only) option creates a snapshot that specifically captures the configuration settings of the Nexus Dashboard.
56+
- The O(backup_type=full) option creates a complete snapshot of the entire Nexus Dashboard.
57+
type: str
58+
choices: [ config_only, full ]
59+
default: config_only
60+
aliases: [ type ]
4261
state:
4362
description:
44-
- Use C(backup) for creating a backup of the cluster config.
45-
- Use C(query) for listing all the backed up files.
46-
- Use C(absent) for deleting a backup job.
63+
- Use O(state=backup) for creating and downloading a backup of the cluster config for the ND versions < 3.2.1.
64+
- Use O(state=backup) to create a cluster configuration backup. Automatic download is not supported for the ND versions >= 3.2.1.
65+
- After creation, use O(state=download) to download the backup file.
66+
- 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.
67+
- Use O(state=query) for listing all the backed up files.
68+
- Use O(state=absent) for deleting a backup job.
4769
type: str
48-
choices: [ backup, query, absent ]
70+
choices: [ backup, download, query, absent ]
4971
default: backup
5072
extends_documentation_fragment:
5173
- cisco.nd.modules
5274
- cisco.nd.check_mode
5375
"""
5476

5577
EXAMPLES = r"""
56-
- name: Create a Backup
78+
- name: Create a backup for ND versions < 3.2.1
5779
cisco.nd.nd_backup:
5880
name: nexus
5981
encryption_key: testtest
6082
file_location: ./nexus.tgz
6183
state: backup
6284
85+
- name: Create a remote backup for ND versions >= 3.2.1
86+
cisco.nd.nd_backup:
87+
name: nexus
88+
encryption_key: testtest1
89+
remote_location: remote_machine
90+
state: backup
91+
92+
- name: Create a local backup for ND versions >= 3.2.1
93+
cisco.nd.nd_backup:
94+
name: nexus
95+
encryption_key: testtest1
96+
state: backup
97+
98+
- name: Create a backup and download it to the local machine for ND versions >= 3.2.1
99+
cisco.nd.nd_backup:
100+
name: nexus
101+
file_location: ./nexus.tgz
102+
encryption_key: testtest1
103+
state: backup
104+
105+
- name: Download a local/remote backup for ND versions >= 3.2.1
106+
cisco.nd.nd_backup:
107+
name: nexus
108+
state: download
109+
file_location: ./nexus.tgz
110+
63111
- name: Query a Backup job
64112
cisco.nd.nd_backup:
65113
name: nexus
@@ -83,6 +131,7 @@
83131
from ansible.module_utils._text import to_bytes
84132
from ansible.module_utils.basic import AnsibleModule
85133
from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule, nd_argument_spec, write_file
134+
from ansible_collections.cisco.nd.plugins.module_utils.utils import snake_to_camel
86135

87136

88137
def main():
@@ -92,15 +141,18 @@ def main():
92141
encryption_key=dict(type="str", no_log=False),
93142
file_location=dict(type="str"),
94143
backup_key=dict(type="str", no_log=False),
95-
state=dict(type="str", default="backup", choices=["backup", "query", "absent"]),
144+
remote_location=dict(type="str"),
145+
backup_type=dict(type="str", default="config_only", choices=["config_only", "full"], aliases=["type"]),
146+
state=dict(type="str", default="backup", choices=["backup", "download", "query", "absent"]),
96147
)
97148

98149
module = AnsibleModule(
99150
argument_spec=argument_spec,
100151
supports_check_mode=True,
101152
required_if=[
102-
["state", "backup", ["name", "encryption_key", "file_location"]],
153+
["state", "backup", ["name", "encryption_key"]],
103154
["state", "absent", ["name"]],
155+
["state", "download", ["name", "file_location"]],
104156
],
105157
)
106158

@@ -110,8 +162,75 @@ def main():
110162
encryption_key = nd.params.get("encryption_key")
111163
backup_key = nd.params.get("backup_key")
112164
file_location = nd.params.get("file_location")
165+
remote_location = nd.params.get("remote_location")
166+
backup_type = nd.params.get("backup_type")
113167
state = nd.params.get("state")
114168

169+
if nd.version < "3.2.1":
170+
if not file_location and state in ["backup", "download"]:
171+
nd.fail_json("Parameter 'file_location' is required when state is 'backup|download' for ND versions < 3.2.1.")
172+
nd_backup_before_3_2_1(module, nd, name, encryption_key, file_location, backup_key, state)
173+
elif nd.version >= "3.2.1":
174+
nd_backup_from_3_2_1(module, nd, name, encryption_key, file_location, remote_location, backup_type, state)
175+
176+
nd.exit_json()
177+
178+
179+
def nd_backup_from_3_2_1(module, nd, name, encryption_key, file_location, remote_location, backup_type, state):
180+
if encryption_key is not None:
181+
if len(encryption_key) < 8:
182+
nd.fail_json("Please provide a minimum of 8 alphanumeric characters for the encryption key.")
183+
elif not (any(char.isalpha() for char in encryption_key) and any(char.isdigit() for char in encryption_key) and encryption_key.isalnum()):
184+
nd.fail_json("The encryption_key must contain at least one letter and one number, and have a minimum length of 8 characters.")
185+
186+
path = "/api/v1/infra/backups"
187+
backups = nd.query_obj(path)
188+
if name and backups:
189+
for backup in backups.get("backups", []):
190+
if backup.get("name") == name:
191+
nd.existing = backup
192+
break
193+
else:
194+
nd.existing = backups.get("backups", [])
195+
196+
if state == "absent" and nd.existing:
197+
nd.previous = nd.existing
198+
if not module.check_mode:
199+
nd.request("{0}/{1}".format(path, name), method="DELETE")
200+
nd.existing = {}
201+
202+
elif state == "backup":
203+
if not nd.existing:
204+
payload = {
205+
"name": name,
206+
"type": snake_to_camel(backup_type),
207+
"destination": remote_location if remote_location else "",
208+
"encryptionKey": encryption_key,
209+
}
210+
nd.sanitize(payload, collate=True)
211+
212+
if not module.check_mode:
213+
# Creates backup file and returns None
214+
nd.request(path, method="POST", data=payload)
215+
216+
# Fetching the backup object details to set module current value
217+
nd.existing = nd.request("{0}/{1}".format(path, name), method="GET")
218+
219+
if file_location:
220+
response = nd.request("{0}/{1}/actions/download".format(path, name), method="GET", data=None, output_format="raw")
221+
write_file(module, file_location, to_bytes(response))
222+
elif module.check_mode:
223+
nd.existing = nd.proposed
224+
else:
225+
nd.previous = nd.existing
226+
227+
elif state == "download" and file_location and nd.existing:
228+
if not module.check_mode:
229+
response = nd.request("{0}/{1}/actions/download".format(path, name), method="GET", data=None, output_format="raw")
230+
write_file(module, file_location, to_bytes(response))
231+
232+
233+
def nd_backup_before_3_2_1(module, nd, name, encryption_key, file_location, backup_key, state):
115234
if encryption_key is not None and len(encryption_key) < 8:
116235
nd.fail_json("Please provide a minimum of 8 characters for the encryption key.")
117236

@@ -158,8 +277,6 @@ def main():
158277
write_file(module, file_location, to_bytes(response))
159278
nd.existing = nd.proposed
160279

161-
nd.exit_json()
162-
163280

164281
if __name__ == "__main__":
165282
main()

0 commit comments

Comments
 (0)