Skip to content
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

Added nd_service.py and nd_service_instance.py modules to manage ND services #53

Merged
merged 7 commits into from
Oct 31, 2023
16 changes: 16 additions & 0 deletions plugins/module_utils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,19 @@
"action": "action",
"leaf": "leaf",
}

# Allowed states to append sent and proposed values in the task result
ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED = (
"absent",
"present",
"upload",
"restore",
"download",
"move",
"backup",
"enable",
"disable",
"restart",
"delete",
"update",
)
93 changes: 50 additions & 43 deletions plugins/module_utils/nd.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from ansible.module_utils.six.moves.urllib.parse import urlencode
from ansible.module_utils._text import to_native, to_text
from ansible.module_utils.connection import Connection
from ansible_collections.cisco.nd.plugins.module_utils.constants import ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED


def sanitize_dict(dict_to_sanitize, keys=None, values=None, recursive=True, remove_none_values=True):
Expand Down Expand Up @@ -208,7 +209,7 @@ def __init__(self, module):
self.module.warn("Enable debug output because ANSIBLE_DEBUG was set.")
self.params["output_level"] = "debug"

def request(self, path, method=None, data=None, file=None, qs=None, prefix="", file_key="file", output_format="json"):
def request(self, path, method=None, data=None, file=None, qs=None, prefix="", file_key="file", output_format="json", ignore_not_found_error=False):
"""Generic HTTP method for ND requests."""
self.path = path

Expand Down Expand Up @@ -238,7 +239,7 @@ def request(self, path, method=None, data=None, file=None, qs=None, prefix="", f

self.url = info.get("url")
self.httpapi_logs.extend(conn.pop_messages())
info.pop("date")
info.pop("date", None)
except Exception as e:
try:
error_obj = json.loads(to_text(e))
Expand Down Expand Up @@ -292,6 +293,8 @@ def request(self, path, method=None, data=None, file=None, qs=None, prefix="", f
elif "messages" in payload and len(payload.get("messages")) > 0:
self.fail_json(msg="ND Error {code} ({severity}): {message}".format(**payload["messages"][0]), data=data, info=info, payload=payload)
else:
if ignore_not_found_error:
return {}
self.fail_json(msg="ND Error: Unknown error no error code in decoded payload".format(**payload), data=data, info=info, payload=payload)
else:
self.result["raw"] = info.get("raw")
Expand All @@ -304,7 +307,7 @@ def request(self, path, method=None, data=None, file=None, qs=None, prefix="", f
def query_objs(self, path, key=None, **kwargs):
"""Query the ND REST API for objects in a path"""
found = []
objs = self.request(path, method="GET")
objs = self.request(path, method="GET", ignore_not_found_error=kwargs.pop("ignore_not_found_error", False))

if objs == {}:
return found
Expand All @@ -328,7 +331,7 @@ def query_objs(self, path, key=None, **kwargs):
def query_obj(self, path, **kwargs):
"""Query the ND REST API for the whole object at a path"""
prefix = kwargs.pop("prefix", "")
obj = self.request(path, method="GET", prefix=prefix)
obj = self.request(path, method="GET", prefix=prefix, ignore_not_found_error=kwargs.pop("ignore_not_found_error", False))
if obj == {}:
return {}
for kw_key, kw_value in kwargs.items():
Expand All @@ -353,52 +356,56 @@ def sanitize(self, updates, collate=False, required=None, unwanted=None):
required = []
if unwanted is None:
unwanted = []
self.proposed = deepcopy(self.existing)
self.sent = deepcopy(self.existing)

for key in self.existing:
# Remove References
if key.endswith("Ref"):
del self.proposed[key]
del self.sent[key]
continue
if isinstance(self.existing, dict):
self.proposed = deepcopy(self.existing)
self.sent = deepcopy(self.existing)

for key in self.existing:
# Remove References
if key.endswith("Ref"):
del self.proposed[key]
del self.sent[key]
continue

# Removed unwanted keys
elif key in unwanted:
del self.proposed[key]
del self.sent[key]
continue
# Removed unwanted keys
elif key in unwanted:
del self.proposed[key]
del self.sent[key]
continue

# Clean up self.sent
for key in updates:
# Always retain 'id'
if key in required:
if key in self.existing or updates.get(key) is not None:
self.sent[key] = updates.get(key)
continue
# Clean up self.sent
for key in updates:
# Always retain 'id'
if key in required:
if key in self.existing or updates.get(key) is not None:
self.sent[key] = updates.get(key)
continue

# Remove unspecified values
elif not collate and updates.get(key) is None:
if key in self.existing:
del self.sent[key]
continue
# Remove unspecified values
elif not collate and updates.get(key) is None:
if key in self.existing:
del self.sent[key]
continue

# Remove identical values
elif not collate and updates.get(key) == self.existing.get(key):
del self.sent[key]
continue
# Remove identical values
elif not collate and updates.get(key) == self.existing.get(key):
del self.sent[key]
continue

# Add everything else
if updates.get(key) is not None:
self.sent[key] = updates.get(key)
# Add everything else
if updates.get(key) is not None:
self.sent[key] = updates.get(key)

# Update self.proposed
self.proposed.update(self.sent)
# Update self.proposed
self.proposed.update(self.sent)
else:
self.module.warn("Unable to sanitize the proposed and sent attributes because the current object is not a dictionary")
self.proposed = self.sent = deepcopy(updates)

def exit_json(self, **kwargs):
"""Custom written method to exit from module."""

if self.params.get("state") in ("absent", "present", "upload", "restore", "download", "move", "backup"):
if self.params.get("state") in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED:
if self.params.get("output_level") in ("debug", "info"):
self.result["previous"] = self.previous
# FIXME: Modified header only works for PATCH
Expand All @@ -415,7 +422,7 @@ def exit_json(self, **kwargs):
self.result["url"] = self.url
self.result["httpapi_logs"] = self.httpapi_logs

if self.params.get("state") in ("absent", "present"):
if self.params.get("state") in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED:
self.result["sent"] = self.sent
self.result["proposed"] = self.proposed

Expand All @@ -433,7 +440,7 @@ def exit_json(self, **kwargs):
def fail_json(self, msg, **kwargs):
"""Custom written method to return info on failure."""

if self.params.get("state") in ("absent", "present", "backup"):
if self.params.get("state") in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED:
if self.params.get("output_level") in ("debug", "info"):
self.result["previous"] = self.previous
# FIXME: Modified header only works for PATCH
Expand All @@ -451,7 +458,7 @@ def fail_json(self, msg, **kwargs):
self.result["url"] = self.url
self.result["httpapi_logs"] = self.httpapi_logs

if self.params.get("state") in ("absent", "present"):
if self.params.get("state") in ALLOWED_STATES_TO_APPEND_SENT_AND_PROPOSED:
self.result["sent"] = self.sent
self.result["proposed"] = self.proposed

Expand Down
158 changes: 158 additions & 0 deletions plugins/modules/nd_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright: (c) 2023, 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

ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"}

DOCUMENTATION = r"""
---
module: nd_service
short_description: Manages Service Package on Nexus Dashboard.
description:
- Manages Service Package of the Nexus Dashboard.
author:
- Sabari Jaganathan (@sajagana)
options:
import_url:
description:
- The remote location of the Service Package.
aliases: [ url ]
type: str
import_id:
description:
- The ID of the imported Service Package.
aliases: [ id ]
type: str
state:
description:
- Use C(present) for importing a Service Package. The C(present) state is not idempotent.
- Use C(query) for listing all the Service Packages.
- Use C(absent) for deleting a Service Package.
type: str
choices: [ present, query, absent ]
default: present
extends_documentation_fragment: cisco.nd.modules
"""

EXAMPLES = r"""
- name: Import a service package
cisco.nd.nd_service:
import_url: "https://nd_service.cisco.com/cisco-terraform-v0.1.16.aci"
state: present
register: import_result

- name: Query a service package with import_id
cisco.nd.nd_service:
import_id: "{{ import_result.current.metadata.id }}"
state: query
register: query_result_import_id
until:
- query_result_import_id.current is defined
- query_result_import_id.current != {}
- query_result_import_id.current.status.downloadPercentage == 100
retries: 5
delay: 5

- name: Remove a service package with import_id
cisco.nd.nd_service:
import_id: "{{ query_result_import_id.current.metadata.id }}"
state: absent

- name: Remove a service package with import_url that has one match
cisco.nd.nd_service:
import_url: "http://nd_service.cisco.com/cisco-terraform-v0.1.15.aci"
state: absent

- name: Query all service packages with import_url
cisco.nd.nd_service:
import_url: "https://nd_service.cisco.com/cisco-terraform-v0.1.16.aci"
state: query
register: query_reseult_import_url

- name: Remove all service packages with import_id when the query_result_import_url has more than one match
cisco.nd.nd_service:
import_id: "{{ item.metadata.id }}"
state: absent
loop: "{{ query_reseult_import_url.current }}"

- name: Query all imported service packages
cisco.nd.nd_service:
state: query
register: query_results
"""

RETURN = r"""
"""

from ansible.module_utils.basic import AnsibleModule
from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule, nd_argument_spec


def main():
argument_spec = nd_argument_spec()
argument_spec.update(
import_url=dict(type="str", aliases=["url"]),
import_id=dict(type="str", aliases=["id"]),
state=dict(type="str", default="present", choices=["present", "query", "absent"]),
)

module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_if=[
["state", "present", ["import_url"]],
["state", "absent", ["import_id", "import_url"], True],
],
mutually_exclusive=[
("import_url", "import_id"),
],
)

nd = NDModule(module)

import_url = nd.params.get("import_url")
import_id = nd.params.get("import_id")
state = nd.params.get("state")

base_path = "/nexus/infra/api/firmware/v1/servicepackageimports"
if import_id:
# Query a object with meta id
service_package = nd.query_obj("{0}/{1}".format(base_path, import_id), ignore_not_found_error=True)
if service_package:
nd.existing = service_package
else:
service_packages = nd.query_obj(base_path)
if import_url:
# Filter all objects with import url
nd.existing = [service_package for service_package in service_packages.get("items") if service_package.get("spec").get("importURL") == import_url]
else:
nd.existing = service_packages.get("items")

nd.previous = nd.existing

# The state present is not idempotent
if state == "present":
payload = {"spec": {"importURL": import_url}}
nd.sanitize(payload, collate=True)
if not module.check_mode:
nd.existing = nd.request(base_path, method="POST", data=payload)
else:
nd.existing = payload
elif state == "absent":
if len(nd.existing) == 1 or (nd.existing and isinstance(nd.existing, dict)):
if not module.check_mode:
nd.existing = nd.request("{0}/{1}".format(base_path, import_id), method="DELETE")
elif len(nd.existing) > 1 and (nd.existing and isinstance(nd.existing, list)):
nd.fail_json(msg="More than one service package found. Provide a unique import_id to delete the service package")

nd.exit_json()


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