From b9fad183ce479f476f9a7768760e1b6e110eff40 Mon Sep 17 00:00:00 2001 From: Andrew Bailey Date: Fri, 12 Jul 2024 14:17:17 +0000 Subject: [PATCH 01/40] Updates to be included in 2.1.0 --- CHANGELOG.rst | 26 ++- changelogs/.plugin-cache.yaml | 9 +- changelogs/changelog.yaml | 17 ++ plugins/modules/orion_node.py | 23 ++- plugins/modules/orion_node_hardware_health.py | 161 ++++++++++++++++++ plugins/modules/orion_update_node.py | 80 ++++++--- 6 files changed, 280 insertions(+), 36 deletions(-) create mode 100644 plugins/modules/orion_node_hardware_health.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3dc20e1..1fec83f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,26 @@ Solarwinds.Orion Release Notes .. contents:: Topics +v2.1.0 +====== + +Release Summary +--------------- + +Minor Updates to orion_node and orion_update_node modules in addtion to creating orion_node_hardware_health module + +Minor Changes +------------- + +- Added orion_node_hardware_health module. This module allows for adding and removing hardware health sensors in Solarwinds Orion. +- Updated orion_node module to no longer require snmpv3 credential set. +- Updated orion_update_node to allow module to update monitoring from ICMP to SNMPv3. + +Known Issues +------------ + +- orion_node_hardware_health module not idempotent +- orion_update_node updates node from icmp to snmp but gets and error when task is run again when it already is configured for snmp v2.0.0 ====== @@ -91,7 +111,6 @@ Release Summary | Released 2023-12-1 - Major Changes ------------- @@ -105,7 +124,6 @@ Release Summary | Released 2023-09-26 - Major Changes ------------- @@ -124,7 +142,6 @@ Release Summary | Released 2023-08-27 - Minor Changes ------------- @@ -143,7 +160,6 @@ Release Summary | Released 2023-08-10 - Minor Changes ------------- @@ -165,7 +181,6 @@ Release Summary | Released 2023-07-14 - Minor Changes ------------- @@ -186,7 +201,6 @@ Release Summary | Released 2023-03-18 - New Modules ----------- diff --git a/changelogs/.plugin-cache.yaml b/changelogs/.plugin-cache.yaml index 0eea60f..cd02561 100644 --- a/changelogs/.plugin-cache.yaml +++ b/changelogs/.plugin-cache.yaml @@ -6,6 +6,7 @@ plugins: callback: {} cliconf: {} connection: {} + filter: {} httpapi: {} inventory: orion_nodes_inventory: @@ -34,6 +35,11 @@ plugins: name: orion_node_custom_poller namespace: '' version_added: 1.0.0 + orion_node_hardware_health: + description: Manage hardware health polling on a node in Solarwinds Orion + name: orion_node_hardware_health + namespace: '' + version_added: null orion_node_info: description: Gets info about a Node in Solarwinds Orion NPM name: orion_node_info @@ -83,5 +89,6 @@ plugins: netconf: {} shell: {} strategy: {} + test: {} vars: {} -version: 2.0.0 +version: 2.1.0 diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index 689473a..d8e579d 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -185,3 +185,20 @@ releases: fragments: - breaking.yml release_date: '2024-04-18' + 2.1.0: + changes: + known_issues: + - orion_node_hardware_health module not idempotent + - orion_update_node updates node from icmp to snmp but gets and error when task + is run again when it already is configured for snmp + minor_changes: + - Added orion_node_hardware_health module. This module allows for adding and + removing hardware health sensors in Solarwinds Orion. + - Updated orion_node module to no longer require snmpv3 credential set. + - Updated orion_update_node to allow module to update monitoring from ICMP to + SNMPv3. + release_summary: Minor Updates to orion_node and orion_update_node modules in + addtion to creating orion_node_hardware_health module + fragments: + - 2.1.0.yml + release_date: '2024-07-12' diff --git a/plugins/modules/orion_node.py b/plugins/modules/orion_node.py index 1fe0718..4856f58 100644 --- a/plugins/modules/orion_node.py +++ b/plugins/modules/orion_node.py @@ -113,17 +113,19 @@ snmpv3_credential_set: description: - Credential set name for SNMPv3 credentials. - - Required when SNMP version is 3. + - Optional when SNMP version is 3 + type: str + required: false snmpv3_username: description: - Read-Only SNMPv3 username. - - Required when SNMP version is 3. + - Required when SNMP version is 3 type: str required: false snmpv3_auth_method: description: - Authentication method for SNMPv3. - - Required when SNMP version is 3. + - Required when SNMP version is 3 type: str default: SHA1 choices: @@ -133,7 +135,7 @@ snmpv3_auth_key: description: - Authentication passphrase for SNMPv3. - - Required when SNMP version is 3. + - Required when SNMP version is 3 type: str required: false snmpv3_auth_key_is_pwd: @@ -292,9 +294,12 @@ def add_node(module, orion): if module.params['snmp_version'] == '3' and props['ObjectSubType'] == 'SNMP': # Even when using credential set, node creation fails without providing all three properties - props['SNMPV3Username'] = module.params['snmpv3_username'] - props['SNMPV3PrivKey'] = module.params['snmpv3_priv_key'] - props['SNMPV3AuthKey'] = module.params['snmpv3_auth_key'] + if module.params['snmpv3_username']: + props['SNMPV3Username'] = module.params['snmpv3_username'] + if module.params['snmpv3_priv_key']: + props['SNMPV3PrivKey'] = module.params['snmpv3_priv_key'] + if module.params['snmpv3_auth_key']: + props['SNMPV3AuthKey'] = module.params['snmpv3_auth_key'] # Set defaults here instead of at module level, since we only want for snmpv3 nodes if module.params['snmpv3_priv_method']: @@ -325,7 +330,7 @@ def add_node(module, orion): # If we don't use credential sets, each snmpv3 node will create its own credential set # TODO option for read/write sets? - if props['ObjectSubType'] == 'SNMP' and props['SNMPVersion'] == '3': + if props['ObjectSubType'] == 'SNMP' and props['SNMPVersion'] == '3' and module.params['snmpv3_credential_set']: add_credential_set(node, module.params['snmpv3_credential_set'], 'ROSNMPCredentialID') # If Node is a WMI node, assign credential @@ -485,7 +490,7 @@ def main(): required_if=[ ('state', 'present', ('name', 'ip_address', 'polling_method')), ('snmp_version', '2', ['ro_community_string']), - ('snmp_version', '3', ['snmpv3_credential_set', 'snmpv3_username', 'snmpv3_auth_key', 'snmpv3_priv_key']), + ('snmp_version', '3', ['snmpv3_username', 'snmpv3_auth_key', 'snmpv3_priv_key']), ('polling_method', 'SNMP', ['snmp_version']), ('polling_method', 'WMI', ['wmi_credential_set']), ], diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py new file mode 100644 index 0000000..a1a78c5 --- /dev/null +++ b/plugins/modules/orion_node_hardware_health.py @@ -0,0 +1,161 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +from ansible.module_utils.basic import AnsibleModule +try: + from orionsdk import SwisClient + HAS_ORIONSDK = True +except ImportError: + HAS_ORIONSDK = False + +DOCUMENTATION = r''' +--- +module: orion_manage_hardware_health +short_description: Manage hardware health polling on a node in Solarwinds Orion +description: + - This module enables or disables hardware health polling on a node in Solarwinds Orion. +author: "Andrew Bailey (@Andyjb8)" +requirements: + - orionsdk +options: + hostname: + description: + - The Orion server hostname. + required: True + type: str + username: + description: + - The Orion username. + required: True + type: str + password: + description: + - The Orion password. + required: True + type: str + node_name: + description: + - The Caption in Orion. + required: True if node_id not specified + type: str + node_id: + description: + - The node_id in Orion. + required: True if node_name not specified + type: str + polling_method: + description: + - The polling method to be used for hardware health. + required: True when state is present + choices: ['Unknown', 'VMware', 'SnmpDell', 'SnmpHP', 'SnmpIBM', 'VMwareAPI', 'WmiDell', 'WmiHP', 'WmiIBM', 'SnmpCisco', 'SnmpJuniper', 'SnmpNPMHP', 'SnmpF5', 'SnmpDellPowerEdge', 'SnmpDellPowerConnect', 'SnmpDellBladeChassis', 'SnmpHPBladeChassis', 'Forwarded', 'SnmpArista'] + type: str + state: + description: + - Whether to enable (present) or disable (absent) hardware health polling. + required: True + choices: ['present', 'absent'] + type: str +''' + +EXAMPLES = r''' +--- +- name: Enable hardware health polling on Cisco node + orion_manage_hardware_health: + hostname: "server" + username: "admin" + password: "pass" + node_name: "{{ inventory_hostname }}" + polling_method: SnmpCisco + state: present + delegate_to: localhost + +- name: Disable hardware health polling on Juniper node + orion_manage_hardware_health: + hostname: "server" + username: "admin" + password: "pass" + node_name: "{{ inventory_hostname }}" + state: absent + delegate_to: localhost +''' + +RETURN = r''' +# Default return values +''' + +# Mapping of polling method names to their corresponding IDs +POLLING_METHOD_MAP = { + 'Unknown': 0, + 'VMware': 1, + 'SnmpDell': 2, + 'SnmpHP': 3, + 'SnmpIBM': 4, + 'VMwareAPI': 5, + 'WmiDell': 6, + 'WmiHP': 7, + 'WmiIBM': 8, + 'SnmpCisco': 9, + 'SnmpJuniper': 10, + 'SnmpNPMHP': 11, + 'SnmpF5': 12, + 'SnmpDellPowerEdge': 13, + 'SnmpDellPowerConnect': 14, + 'SnmpDellBladeChassis': 15, + 'SnmpHPBladeChassis': 16, + 'Forwarded': 17, + 'SnmpArista': 18 +} + +def main(): + module_args = dict( + hostname=dict(type='str', required=True), + username=dict(type='str', required=True), + password=dict(type='str', required=True, no_log=True), + node_name=dict(type='str', required=False), # Now accept node_name + node_id=dict(type='str', required=False), # Make node_id optional + polling_method=dict(type='str', required=False, choices=list(POLLING_METHOD_MAP.keys())), # Not required for absent state + state=dict(type='str', required=True, choices=['present', 'absent']), + ) + + module = AnsibleModule( + argument_spec=module_args, + required_one_of=[('node_name', 'node_id')], # Require at least one + supports_check_mode=True + ) + + if not HAS_ORIONSDK: + module.fail_json(msg="The orionsdk module is required") + + hostname = module.params['hostname'] + username = module.params['username'] + password = module.params['password'] + node_name = module.params['node_name'] + node_id = module.params['node_id'] + polling_method = module.params.get('polling_method') + state = module.params['state'] + + swis = SwisClient(hostname, username, password) + + # Resolve node_name to node_id if node_name is provided + if node_name: + try: + results = swis.query(f"SELECT NodeID FROM Orion.Nodes WHERE Caption='{node_name}'") + node_id = "N:" + str(results['results'][0]['NodeID']) + except Exception as e: + module.fail_json(msg=f"Failed to resolve node name to ID: {e}") + + try: + if state == 'present': + if not polling_method: + module.fail_json(msg="polling_method is required when state is present") + polling_method_id = POLLING_METHOD_MAP[polling_method] + swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'EnableHardwareHealth', node_id, polling_method_id) + module.exit_json(changed=True) + elif state == 'absent': + swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'DisableHardwareHealth', node_id) + module.exit_json(changed=True) + except Exception as e: + module.fail_json(msg=str(e)) + +if __name__ == '__main__': + main() diff --git a/plugins/modules/orion_update_node.py b/plugins/modules/orion_update_node.py index 12a98fe..bc7c2bd 100644 --- a/plugins/modules/orion_update_node.py +++ b/plugins/modules/orion_update_node.py @@ -46,16 +46,19 @@ Caption: "{{ new_node_caption }}" delegate_to: localhost -- name: Update node to SNMP polling +- name: Update node to SNMPv3 polling solarwinds.orion.orion_update_node: hostname: "{{ solarwinds_server }}" username: "{{ solarwinds_user }}" - password: "{{ solarwinds_pass }}" - name: "{{ node_name }}" - properties: - ObjectSubType: SNMP - SNMPVersion: 2 - Community: "{{ ro_community_string }}" + password: "{{ solarwinds_password }}" + name: "{{ inventory_hostname }}" + polling_method: SNMP + snmp_version: "3" + snmpv3_username: "{{ snmpv3_user }}" + snmpv3_auth_method: "{{ snmpv3_auth }}{{ snmpv3_auth_level }}" + snmpv3_auth_key: "{{ snmpv3_auth_pass }}" + snmpv3_priv_method: "{{ snmpv3_priv }}{{ snmpv3_priv_level }}" + snmpv3_priv_key: "{{ snmpv3_priv_pass }}" delegate_to: localhost ''' @@ -80,25 +83,34 @@ } ''' -import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec + try: import orionsdk from orionsdk import SwisClient HAS_ORION = True except ImportError: HAS_ORION = False -except Exception: - raise Exception - -requests.packages.urllib3.disable_warnings() +def properties_need_update(current_node, desired_properties): + for key, value in desired_properties.items(): + if key not in current_node or current_node[key] != value: + return True + return False def main(): argument_spec = orion_argument_spec argument_spec.update( - properties=dict(required=False, default={}, type='dict') + polling_method=dict(required=False, choices=['External', 'ICMP', 'SNMP', 'WMI', 'Agent']), + snmp_version=dict(required=False, choices=['2', '3']), + snmpv3_username=dict(required=False, type='str'), + snmpv3_auth_method=dict(required=False, choices=['SHA1', 'MD5']), + snmpv3_auth_key=dict(required=False, type='str', no_log=True), + snmpv3_priv_method=dict(required=False, choices=['DES56', 'AES128', 'AES192', 'AES256']), + snmpv3_priv_key=dict(required=False, type='str', no_log=True), + snmpv3_auth_key_is_pwd=dict(required=False, type='bool', default=True), + snmpv3_priv_key_is_pwd=dict(required=False, type='bool', default=True), ) module = AnsibleModule( argument_spec, @@ -115,15 +127,43 @@ def main(): if not node: module.fail_json(skipped=True, msg='Node not found') + update_properties = {} + + if module.params['polling_method']: + update_properties['ObjectSubType'] = module.params['polling_method'].upper() + + if module.params['snmp_version']: + update_properties['SNMPVersion'] = module.params['snmp_version'] + + if module.params['snmpv3_username']: + update_properties['SNMPV3Username'] = module.params['snmpv3_username'] + + if module.params['snmpv3_auth_method']: + update_properties['SNMPV3AuthMethod'] = module.params['snmpv3_auth_method'] + + if module.params['snmpv3_auth_key']: + update_properties['SNMPV3AuthKey'] = module.params['snmpv3_auth_key'] + update_properties['SNMPV3AuthKeyIsPwd'] = module.params['snmpv3_auth_key_is_pwd'] + + if module.params['snmpv3_priv_method']: + update_properties['SNMPV3PrivMethod'] = module.params['snmpv3_priv_method'] + + if module.params['snmpv3_priv_key']: + update_properties['SNMPV3PrivKey'] = module.params['snmpv3_priv_key'] + update_properties['SNMPV3PrivKeyIsPwd'] = module.params['snmpv3_priv_key_is_pwd'] + try: if module.check_mode: - module.exit_json(changed=True, orion_node=node) + module.exit_json(changed=properties_need_update(node, update_properties), orion_node=node) else: - orion.swis.update(node['uri'], **module.params['properties']) - module.exit_json(changed=True, orion_node=node) + if properties_need_update(node, update_properties): + orion.swis.update(node['uri'], **update_properties) + updated_node = orion.get_node() + module.exit_json(changed=True, orion_node=updated_node) + else: + module.exit_json(changed=False, orion_node=node) except Exception as OrionException: - module.fail_json(msg='Failed to update {0}'.format(str(OrionException))) - - + module.fail_json(msg='Failed to update node: {0}'.format(str(OrionException))) + if __name__ == "__main__": - main() + main() \ No newline at end of file From dcae5b94de0138f8fc1b7fc1ea3bda137d47fb9a Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Mon, 15 Jul 2024 17:19:22 -0500 Subject: [PATCH 02/40] add missing periods to DOCUMENTATION block --- plugins/modules/orion_node.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/modules/orion_node.py b/plugins/modules/orion_node.py index 4856f58..9b5828f 100644 --- a/plugins/modules/orion_node.py +++ b/plugins/modules/orion_node.py @@ -87,7 +87,7 @@ type: str rw_community_string: description: - - SNMP Read-Write Community string + - SNMP Read-Write Community string. required: false type: str snmp_version: @@ -113,19 +113,19 @@ snmpv3_credential_set: description: - Credential set name for SNMPv3 credentials. - - Optional when SNMP version is 3 + - Optional when SNMP version is 3. type: str required: false snmpv3_username: description: - Read-Only SNMPv3 username. - - Required when SNMP version is 3 + - Required when SNMP version is 3. type: str required: false snmpv3_auth_method: description: - Authentication method for SNMPv3. - - Required when SNMP version is 3 + - Required when SNMP version is 3. type: str default: SHA1 choices: @@ -135,7 +135,7 @@ snmpv3_auth_key: description: - Authentication passphrase for SNMPv3. - - Required when SNMP version is 3 + - Required when SNMP version is 3. type: str required: false snmpv3_auth_key_is_pwd: @@ -158,7 +158,7 @@ required: false snmpv3_priv_key: description: - - Privacy passphrase for SNMPv3 + - Privacy passphrase for SNMPv3. type: str required: false snmpv3_priv_key_is_pwd: @@ -170,8 +170,8 @@ required: false wmi_credential_set: description: - - 'Credential Name already configured in NPM Found under "Manage Windows Credentials" section of the Orion website (Settings)' - - "Note: creation of credentials are not supported at this time" + - 'Credential Name already configured in NPM Found under "Manage Windows Credentials" section of the Orion website (Settings).' + - "Note: creation of credentials are not supported at this time." - Required if I(polling_method=wmi). required: false type: str From dc437c4039e84b408e56a24f978941594ca5ae54 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Mon, 15 Jul 2024 17:22:39 -0500 Subject: [PATCH 03/40] add __future__ import and __metaclass__, moved other imports below RETURN to match Ansible style guide --- plugins/modules/orion_node_hardware_health.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index a1a78c5..6aa05ec 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -1,12 +1,8 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -from ansible.module_utils.basic import AnsibleModule -try: - from orionsdk import SwisClient - HAS_ORIONSDK = True -except ImportError: - HAS_ORIONSDK = False +from __future__ import absolute_import, division, print_function +__metaclass__ = type DOCUMENTATION = r''' --- @@ -83,6 +79,13 @@ # Default return values ''' +from ansible.module_utils.basic import AnsibleModule +try: + from orionsdk import SwisClient + HAS_ORIONSDK = True +except ImportError: + HAS_ORIONSDK = False + # Mapping of polling method names to their corresponding IDs POLLING_METHOD_MAP = { 'Unknown': 0, From 26d92b5bc6c082bbd2c937824d99a0e4a9450b2c Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Mon, 15 Jul 2024 17:23:06 -0500 Subject: [PATCH 04/40] fix module name in DOCUMENTATION --- plugins/modules/orion_node_hardware_health.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index 6aa05ec..d702e6a 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -6,7 +6,7 @@ DOCUMENTATION = r''' --- -module: orion_manage_hardware_health +module: orion_node_hardware_health short_description: Manage hardware health polling on a node in Solarwinds Orion description: - This module enables or disables hardware health polling on a node in Solarwinds Orion. From e9b33faacaca0fbd8d26c064a2721b1a0e4f33da Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Mon, 15 Jul 2024 17:30:42 -0500 Subject: [PATCH 05/40] import and use orion_argument_spec. use doc fragments. --- plugins/modules/orion_node_hardware_health.py | 45 +++++-------------- 1 file changed, 10 insertions(+), 35 deletions(-) diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index d702e6a..7242340 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -14,31 +14,6 @@ requirements: - orionsdk options: - hostname: - description: - - The Orion server hostname. - required: True - type: str - username: - description: - - The Orion username. - required: True - type: str - password: - description: - - The Orion password. - required: True - type: str - node_name: - description: - - The Caption in Orion. - required: True if node_id not specified - type: str - node_id: - description: - - The node_id in Orion. - required: True if node_name not specified - type: str polling_method: description: - The polling method to be used for hardware health. @@ -51,6 +26,9 @@ required: True choices: ['present', 'absent'] type: str +extends_documentation_fragment: + - solarwinds.orion.orion_auth_options + - solarwinds.orion.orion_node_options ''' EXAMPLES = r''' @@ -80,6 +58,8 @@ ''' from ansible.module_utils.basic import AnsibleModule +from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec + try: from orionsdk import SwisClient HAS_ORIONSDK = True @@ -110,19 +90,14 @@ } def main(): - module_args = dict( - hostname=dict(type='str', required=True), - username=dict(type='str', required=True), - password=dict(type='str', required=True, no_log=True), - node_name=dict(type='str', required=False), # Now accept node_name - node_id=dict(type='str', required=False), # Make node_id optional + argument_spec = orion_argument_spec + argument_spec.update( + state=dict(required=True, choices=['present', 'absent']), polling_method=dict(type='str', required=False, choices=list(POLLING_METHOD_MAP.keys())), # Not required for absent state - state=dict(type='str', required=True, choices=['present', 'absent']), ) - module = AnsibleModule( - argument_spec=module_args, - required_one_of=[('node_name', 'node_id')], # Require at least one + argument_spec, + required_one_of=[('name', 'node_id', 'ip_address')], supports_check_mode=True ) From 57113d3f01ba202b7a63987a153232c9af433279 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Mon, 15 Jul 2024 17:42:39 -0500 Subject: [PATCH 06/40] use OrionModule for creating swis connection, getting node object --- plugins/modules/orion_node_hardware_health.py | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index 7242340..d53440a 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -104,33 +104,23 @@ def main(): if not HAS_ORIONSDK: module.fail_json(msg="The orionsdk module is required") - hostname = module.params['hostname'] - username = module.params['username'] - password = module.params['password'] - node_name = module.params['node_name'] - node_id = module.params['node_id'] + orion = OrionModule(module) + node = orion.get_node() + if not node: + module.fail_json(skipped=True, msg='Node not found') + polling_method = module.params.get('polling_method') state = module.params['state'] - swis = SwisClient(hostname, username, password) - - # Resolve node_name to node_id if node_name is provided - if node_name: - try: - results = swis.query(f"SELECT NodeID FROM Orion.Nodes WHERE Caption='{node_name}'") - node_id = "N:" + str(results['results'][0]['NodeID']) - except Exception as e: - module.fail_json(msg=f"Failed to resolve node name to ID: {e}") - try: if state == 'present': if not polling_method: module.fail_json(msg="polling_method is required when state is present") polling_method_id = POLLING_METHOD_MAP[polling_method] - swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'EnableHardwareHealth', node_id, polling_method_id) + orion.swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'EnableHardwareHealth', node['netobjectid'], polling_method_id) module.exit_json(changed=True) elif state == 'absent': - swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'DisableHardwareHealth', node_id) + orion.swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'DisableHardwareHealth', node['netobjectid']) module.exit_json(changed=True) except Exception as e: module.fail_json(msg=str(e)) From fb34414730bcf7162c165446586a22eff15b630f Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Mon, 15 Jul 2024 17:46:35 -0500 Subject: [PATCH 07/40] use required_if instead of manual logic to require a param --- plugins/modules/orion_node_hardware_health.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index d53440a..93da4d3 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -98,7 +98,10 @@ def main(): module = AnsibleModule( argument_spec, required_one_of=[('name', 'node_id', 'ip_address')], - supports_check_mode=True + supports_check_mode=True, + required_if=[ + ('state', 'present', ['polling_method']) + ], ) if not HAS_ORIONSDK: @@ -109,14 +112,11 @@ def main(): if not node: module.fail_json(skipped=True, msg='Node not found') - polling_method = module.params.get('polling_method') state = module.params['state'] try: if state == 'present': - if not polling_method: - module.fail_json(msg="polling_method is required when state is present") - polling_method_id = POLLING_METHOD_MAP[polling_method] + polling_method_id = POLLING_METHOD_MAP[module.params['polling_method']] orion.swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'EnableHardwareHealth', node['netobjectid'], polling_method_id) module.exit_json(changed=True) elif state == 'absent': From be878321def06aa116e7ae30a658e79a1e83a98e Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Mon, 15 Jul 2024 17:52:41 -0500 Subject: [PATCH 08/40] add the orion_node to the return --- plugins/modules/orion_node_hardware_health.py | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index 93da4d3..3b209c5 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -54,7 +54,23 @@ ''' RETURN = r''' -# Default return values +orion_node: + description: Info about an orion node. + returned: always + type: dict + sample: { + "caption": "localhost", + "ipaddress": "127.0.0.1", + "netobjectid": "N:12345", + "nodeid": "12345", + "objectsubtype": "SNMP", + "status": 1, + "statusdescription": "Node status is Up.", + "unmanaged": false, + "unmanagefrom": "1899-12-30T00:00:00+00:00", + "unmanageuntil": "1899-12-30T00:00:00+00:00", + "uri": "swis://host.domain.com/Orion/Orion.Nodes/NodeID=12345" + } ''' from ansible.module_utils.basic import AnsibleModule @@ -111,19 +127,20 @@ def main(): node = orion.get_node() if not node: module.fail_json(skipped=True, msg='Node not found') - - state = module.params['state'] + changed=False try: - if state == 'present': + if module.params['state'] == 'present': polling_method_id = POLLING_METHOD_MAP[module.params['polling_method']] orion.swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'EnableHardwareHealth', node['netobjectid'], polling_method_id) - module.exit_json(changed=True) - elif state == 'absent': + changed=True + elif module.params['state'] == 'absent': orion.swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'DisableHardwareHealth', node['netobjectid']) - module.exit_json(changed=True) + changed=True except Exception as e: module.fail_json(msg=str(e)) + module.exit_json(changed=changed, orion_node=node) + if __name__ == '__main__': main() From 0b597686744eb53bfac81fb52d8efedb140e9506 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Mon, 15 Jul 2024 17:55:03 -0500 Subject: [PATCH 09/40] add fqcn to examples --- plugins/modules/orion_node_hardware_health.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index 3b209c5..2bca03b 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -34,7 +34,7 @@ EXAMPLES = r''' --- - name: Enable hardware health polling on Cisco node - orion_manage_hardware_health: + solarwinds.orion.orion_manage_hardware_health: hostname: "server" username: "admin" password: "pass" @@ -44,7 +44,7 @@ delegate_to: localhost - name: Disable hardware health polling on Juniper node - orion_manage_hardware_health: + solarwinds.orion.orion_manage_hardware_health: hostname: "server" username: "admin" password: "pass" From 907363bd2698788c49d8d8950fa16214f899b6d9 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Mon, 15 Jul 2024 18:13:24 -0500 Subject: [PATCH 10/40] since supports_check_mode is turned on, add check mode logic --- plugins/modules/orion_node_hardware_health.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index 2bca03b..a00788a 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -127,16 +127,22 @@ def main(): node = orion.get_node() if not node: module.fail_json(skipped=True, msg='Node not found') - changed=False + changed = False try: if module.params['state'] == 'present': - polling_method_id = POLLING_METHOD_MAP[module.params['polling_method']] - orion.swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'EnableHardwareHealth', node['netobjectid'], polling_method_id) - changed=True + if module.check_mode: + changed = True + else: + polling_method_id = POLLING_METHOD_MAP[module.params['polling_method']] + orion.swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'EnableHardwareHealth', node['netobjectid'], polling_method_id) + changed = True elif module.params['state'] == 'absent': - orion.swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'DisableHardwareHealth', node['netobjectid']) - changed=True + if module.check_mode: + changed = True + else: + orion.swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'DisableHardwareHealth', node['netobjectid']) + changed = True except Exception as e: module.fail_json(msg=str(e)) From f51b3a0497cca2da419a27932d7a48c3f68fd8c8 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Mon, 15 Jul 2024 18:16:16 -0500 Subject: [PATCH 11/40] revert change to parameters, but keep check for properties_need_update --- plugins/modules/orion_update_node.py | 94 ++++++++++++---------------- 1 file changed, 41 insertions(+), 53 deletions(-) diff --git a/plugins/modules/orion_update_node.py b/plugins/modules/orion_update_node.py index bc7c2bd..2cf5571 100644 --- a/plugins/modules/orion_update_node.py +++ b/plugins/modules/orion_update_node.py @@ -46,19 +46,34 @@ Caption: "{{ new_node_caption }}" delegate_to: localhost +- name: Update node to SNMPv2 polling + solarwinds.orion.orion_update_node: + hostname: "{{ solarwinds_server }}" + username: "{{ solarwinds_user }}" + password: "{{ solarwinds_pass }}" + name: "{{ node_name }}" + properties: + ObjectSubType: SNMP + SNMPVersion: 2 + Community: "{{ ro_community_string }}" + delegate_to: localhost + - name: Update node to SNMPv3 polling solarwinds.orion.orion_update_node: hostname: "{{ solarwinds_server }}" username: "{{ solarwinds_user }}" - password: "{{ solarwinds_password }}" - name: "{{ inventory_hostname }}" - polling_method: SNMP - snmp_version: "3" - snmpv3_username: "{{ snmpv3_user }}" - snmpv3_auth_method: "{{ snmpv3_auth }}{{ snmpv3_auth_level }}" - snmpv3_auth_key: "{{ snmpv3_auth_pass }}" - snmpv3_priv_method: "{{ snmpv3_priv }}{{ snmpv3_priv_level }}" - snmpv3_priv_key: "{{ snmpv3_priv_pass }}" + password: "{{ solarwinds_pass }}" + name: "{{ node_name }}" + properties: + ObjectSubType: SNMP + SNMPVersion: 3 + SNMPV3Username: + SNMPV3AuthMethod: + SNMPV3AuthKey: + SNMPV3AuthKeyIsPwd: + SNMPV3PrivMethod: + SNMPV3PrivKey: + SNMPV3PrivKeyIsPwd: delegate_to: localhost ''' @@ -83,15 +98,19 @@ } ''' +import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec - try: import orionsdk from orionsdk import SwisClient HAS_ORION = True except ImportError: HAS_ORION = False +except Exception: + raise Exception + +requests.packages.urllib3.disable_warnings() def properties_need_update(current_node, desired_properties): for key, value in desired_properties.items(): @@ -102,15 +121,7 @@ def properties_need_update(current_node, desired_properties): def main(): argument_spec = orion_argument_spec argument_spec.update( - polling_method=dict(required=False, choices=['External', 'ICMP', 'SNMP', 'WMI', 'Agent']), - snmp_version=dict(required=False, choices=['2', '3']), - snmpv3_username=dict(required=False, type='str'), - snmpv3_auth_method=dict(required=False, choices=['SHA1', 'MD5']), - snmpv3_auth_key=dict(required=False, type='str', no_log=True), - snmpv3_priv_method=dict(required=False, choices=['DES56', 'AES128', 'AES192', 'AES256']), - snmpv3_priv_key=dict(required=False, type='str', no_log=True), - snmpv3_auth_key_is_pwd=dict(required=False, type='bool', default=True), - snmpv3_priv_key_is_pwd=dict(required=False, type='bool', default=True), + properties=dict(required=False, default={}, type='dict') ) module = AnsibleModule( argument_spec, @@ -127,43 +138,20 @@ def main(): if not node: module.fail_json(skipped=True, msg='Node not found') - update_properties = {} - - if module.params['polling_method']: - update_properties['ObjectSubType'] = module.params['polling_method'].upper() - - if module.params['snmp_version']: - update_properties['SNMPVersion'] = module.params['snmp_version'] - - if module.params['snmpv3_username']: - update_properties['SNMPV3Username'] = module.params['snmpv3_username'] - - if module.params['snmpv3_auth_method']: - update_properties['SNMPV3AuthMethod'] = module.params['snmpv3_auth_method'] - - if module.params['snmpv3_auth_key']: - update_properties['SNMPV3AuthKey'] = module.params['snmpv3_auth_key'] - update_properties['SNMPV3AuthKeyIsPwd'] = module.params['snmpv3_auth_key_is_pwd'] - - if module.params['snmpv3_priv_method']: - update_properties['SNMPV3PrivMethod'] = module.params['snmpv3_priv_method'] - - if module.params['snmpv3_priv_key']: - update_properties['SNMPV3PrivKey'] = module.params['snmpv3_priv_key'] - update_properties['SNMPV3PrivKeyIsPwd'] = module.params['snmpv3_priv_key_is_pwd'] + changed = False try: if module.check_mode: - module.exit_json(changed=properties_need_update(node, update_properties), orion_node=node) + changed = True else: - if properties_need_update(node, update_properties): - orion.swis.update(node['uri'], **update_properties) - updated_node = orion.get_node() - module.exit_json(changed=True, orion_node=updated_node) - else: - module.exit_json(changed=False, orion_node=node) + if properties_need_update(module.params['properties'], node): + orion.swis.update(node['uri'], **module.params['properties']) + changed = True except Exception as OrionException: - module.fail_json(msg='Failed to update node: {0}'.format(str(OrionException))) - + module.fail_json(msg='Failed to update {0}'.format(str(OrionException))) + + module.exit_json(changed=changed, orion_node=node) + + if __name__ == "__main__": - main() \ No newline at end of file + main() From a1aa57f627e3ac19526af5659284e1533fd1ecce Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Mon, 15 Jul 2024 18:29:22 -0500 Subject: [PATCH 12/40] add logic I think that's needed to validate if properties need updating. add example I think should work, needs testing --- plugins/modules/orion_update_node.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/plugins/modules/orion_update_node.py b/plugins/modules/orion_update_node.py index 2cf5571..f6af009 100644 --- a/plugins/modules/orion_update_node.py +++ b/plugins/modules/orion_update_node.py @@ -67,13 +67,11 @@ properties: ObjectSubType: SNMP SNMPVersion: 3 - SNMPV3Username: - SNMPV3AuthMethod: - SNMPV3AuthKey: - SNMPV3AuthKeyIsPwd: - SNMPV3PrivMethod: - SNMPV3PrivKey: - SNMPV3PrivKeyIsPwd: + SNMPV3Username: "{{ snmpv3_user }}" + SNMPV3AuthMethod: "{{ snmpv3_auth }}{{ snmpv3_auth_level }}" + SNMPV3AuthKey: "{{ snmpv3_auth_pass }}" + SNMPV3PrivMethod: "{{ snmpv3_priv }}{{ snmpv3_priv_level }}" + SNMPV3PrivKey: "{{ snmpv3_priv_pass }}" delegate_to: localhost ''' @@ -139,12 +137,19 @@ def main(): module.fail_json(skipped=True, msg='Node not found') changed = False + + # fields to be updated might not necessarily be in the limited scope of orion.get_node(), so we specifically get them here + fields = "NodeID, " + for key in module.params['properties']: + fields += "{0}, ".format(key) + query = "SELECT {0} FROM Orion.Nodes WHERE NodeID = '{1}'".format(fields, node['node_id']) + current_node_properties = orion.swis_query(query) try: if module.check_mode: changed = True else: - if properties_need_update(module.params['properties'], node): + if properties_need_update(current_node_properties, module.params['properties']): orion.swis.update(node['uri'], **module.params['properties']) changed = True except Exception as OrionException: From ebcdefc4ef20f8e67ff6be3361fae1dde34bc9dc Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Fri, 2 Aug 2024 13:13:04 -0500 Subject: [PATCH 13/40] fix name param in examples --- plugins/modules/orion_node_hardware_health.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index a00788a..7157954 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -38,7 +38,7 @@ hostname: "server" username: "admin" password: "pass" - node_name: "{{ inventory_hostname }}" + name: "{{ inventory_hostname }}" polling_method: SnmpCisco state: present delegate_to: localhost @@ -48,7 +48,7 @@ hostname: "server" username: "admin" password: "pass" - node_name: "{{ inventory_hostname }}" + name: "{{ inventory_hostname }}" state: absent delegate_to: localhost ''' From 2335d25203ca3b6bd5b7aa58e97f3fd5ed49b558 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Fri, 2 Aug 2024 13:16:53 -0500 Subject: [PATCH 14/40] add task for adding hardware health poller --- roles/orion_node/tasks/main.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/roles/orion_node/tasks/main.yml b/roles/orion_node/tasks/main.yml index ec6e4ff..618a428 100644 --- a/roles/orion_node/tasks/main.yml +++ b/roles/orion_node/tasks/main.yml @@ -104,3 +104,14 @@ property_value: "{{ item.value }}" loop: "{{ orion_node_custom_properties }}" delegate_to: localhost + +- name: Add Hardware Health poller if orion_node_hardware_health_poller is defined + when: orion_node_hardware_health is defined + solarwinds.orion.orion_node_hardware_health: + hostname: "{{ orion_node_solarwinds_server }}" + username: "{{ orion_node_solarwinds_username }}" + password: "{{ orion_node_solarwinds_password }}" + state: present + polling_method: "{{ orion_node_hardware_health_poller }}" + delegate_to: localhost +... From 6f31310eb5b6b890c691c947c22585417a14d32c Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Fri, 2 Aug 2024 13:42:38 -0500 Subject: [PATCH 15/40] add default var orion_node_ncm --- roles/orion_node/defaults/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/roles/orion_node/defaults/main.yml b/roles/orion_node/defaults/main.yml index 1573802..ec90073 100644 --- a/roles/orion_node/defaults/main.yml +++ b/roles/orion_node/defaults/main.yml @@ -12,3 +12,4 @@ orion_node_snmp_pollers: - name: N.Memory.SNMP.NetSnmpReal enabled: true orion_node_discover_interfaces: false +orion_node_ncm: false From 1d2b89fbd2ca8ffa6b9f279c1b897fd07f1ea566 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Fri, 2 Aug 2024 13:42:52 -0500 Subject: [PATCH 16/40] add task for adding node to ncm --- roles/orion_node/tasks/main.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/roles/orion_node/tasks/main.yml b/roles/orion_node/tasks/main.yml index 618a428..852e752 100644 --- a/roles/orion_node/tasks/main.yml +++ b/roles/orion_node/tasks/main.yml @@ -112,6 +112,16 @@ username: "{{ orion_node_solarwinds_username }}" password: "{{ orion_node_solarwinds_password }}" state: present + name: "{{ orion_node_caption_name }}" polling_method: "{{ orion_node_hardware_health_poller }}" delegate_to: localhost + +- name: Add node to NCM if orion_node_ncm is true + when: orion_node_ncm + solarwinds.orion.orion_node_ncm: + hostname: "{{ orion_node_solarwinds_server }}" + username: "{{ orion_node_solarwinds_username }}" + password: "{{ orion_node_solarwinds_password }}" + state: present + name: "{{ orion_node_caption_name }}" ... From f4e8a35c17e27bc9ca7e6c14877aa474b69d0cb8 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Fri, 2 Aug 2024 13:43:12 -0500 Subject: [PATCH 17/40] add vars to readme. add yaml markup to example. --- roles/orion_node/README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/roles/orion_node/README.md b/roles/orion_node/README.md index 3e35e06..e20364c 100644 --- a/roles/orion_node/README.md +++ b/roles/orion_node/README.md @@ -20,6 +20,7 @@ orion_node_ip_address - Default {{ ansible_facts.default_ipv4.address }}, overri orion_node_polling_method - Default ICMP orion_node_snmp_pollers - list, elements are dicts (name, enabled(bool)). Default is only CPU and Memory pollers. orion_node_discover_interfaces - Default false, whether to discover and add all interfaces when polling method is SNMP +orion_node_ncm - Default false, whether to add node to NCM Optional variables orion_node_snmp_version - required when orion_node_polling method is SNMP, set which version of SNMP (choices: 2, 3) @@ -36,14 +37,15 @@ orion_node_interfaces - list, interfaces to monitor orion_node_volumes - list, volumes to monitor orion_node_applications - list, APM templates to add to node orion_node_custom_properties - list, elements are dicts (name, value), custom property names and values to set +orion_node_hardware_health_poller - string, Name of the Hardware Health poller to enable on node Example Playbook ---------------- -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: +```yaml - - name: Add servers to Solarwinds as simple ICMP nodes + - name: Add servers to Solarwinds as SNMPv2 nodes hosts: servers gather_facts: true vars: @@ -51,14 +53,15 @@ Including an example of how to use your role (for instance, with variables passe solarwinds_username: admin solarwinds_password: changeme2345 roles: - - { role: solarwinds.orion.orion_node } + - role: solarwinds.orion.orion_node + orion_node_polling_method: SNMP + orion_node_snmp_version: 2 + orion_node_ro_community_string: community + orion_node_discover_interfaces: true + +``` License ------- GPL-3.0-or-later - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). From c4f4f13292779183d90562adef68e9a6d33efcba Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Fri, 2 Aug 2024 18:11:53 -0500 Subject: [PATCH 18/40] add idempotence to orion_node_hardware_health --- plugins/modules/orion_node_hardware_health.py | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index 7157954..c4e5d5d 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -130,19 +130,24 @@ def main(): changed = False try: + hh_poller = orion.swis_query("SELECT PollingMethod FROM Orion.HardwareHealth.HardwareInfoBase WHERE ParentObjectID = '{0}'".format(node['nodeid'])) if module.params['state'] == 'present': - if module.check_mode: - changed = True - else: - polling_method_id = POLLING_METHOD_MAP[module.params['polling_method']] - orion.swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'EnableHardwareHealth', node['netobjectid'], polling_method_id) - changed = True + polling_method_id = POLLING_METHOD_MAP[module.params['polling_method']] + if not hh_poller: + if module.check_mode: + changed = True + else: + orion.swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'EnableHardwareHealth', node['netobjectid'], polling_method_id) + changed = True + elif hh_poller[0]['PollingMethod'] != polling_method_id: + module.fail_json(msg="HardwareHealth montior exists, but does not match provided polling_method parameter.") elif module.params['state'] == 'absent': - if module.check_mode: - changed = True - else: - orion.swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'DisableHardwareHealth', node['netobjectid']) - changed = True + if hh_poller: + if module.check_mode: + changed = True + else: + orion.swis.invoke('Orion.HardwareHealth.HardwareInfoBase', 'DisableHardwareHealth', node['netobjectid']) + changed = True except Exception as e: module.fail_json(msg=str(e)) From dde9fbef52d475bad54ce4375b6016cd62bcf7c6 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Fri, 2 Aug 2024 19:09:44 -0500 Subject: [PATCH 19/40] fix var name --- roles/orion_node/tasks/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/orion_node/tasks/main.yml b/roles/orion_node/tasks/main.yml index 852e752..42a6b21 100644 --- a/roles/orion_node/tasks/main.yml +++ b/roles/orion_node/tasks/main.yml @@ -106,7 +106,7 @@ delegate_to: localhost - name: Add Hardware Health poller if orion_node_hardware_health_poller is defined - when: orion_node_hardware_health is defined + when: orion_node_hardware_health_poller is defined solarwinds.orion.orion_node_hardware_health: hostname: "{{ orion_node_solarwinds_server }}" username: "{{ orion_node_solarwinds_username }}" From 75abbb7fbb2d40e8d4d00c4a53bf1a380a0ce9f8 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Sat, 3 Aug 2024 01:30:48 -0500 Subject: [PATCH 20/40] removed call to fuction for idempotence, which doesn't work when updating values outside of orion.nodes --- plugins/modules/orion_update_node.py | 32 +++++++++------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/plugins/modules/orion_update_node.py b/plugins/modules/orion_update_node.py index f6af009..c85bcd1 100644 --- a/plugins/modules/orion_update_node.py +++ b/plugins/modules/orion_update_node.py @@ -58,7 +58,7 @@ Community: "{{ ro_community_string }}" delegate_to: localhost -- name: Update node to SNMPv3 polling +- name: Update node to SNMPv3 polling. Note you will also need to add SNMP pollers if updating from ICMP solarwinds.orion.orion_update_node: hostname: "{{ solarwinds_server }}" username: "{{ solarwinds_user }}" @@ -67,11 +67,13 @@ properties: ObjectSubType: SNMP SNMPVersion: 3 - SNMPV3Username: "{{ snmpv3_user }}" - SNMPV3AuthMethod: "{{ snmpv3_auth }}{{ snmpv3_auth_level }}" - SNMPV3AuthKey: "{{ snmpv3_auth_pass }}" - SNMPV3PrivMethod: "{{ snmpv3_priv }}{{ snmpv3_priv_level }}" - SNMPV3PrivKey: "{{ snmpv3_priv_pass }}" + SNMPV3Username: "{{ snmpv3_username }}" + SNMPV3AuthMethod: "{{ snmpv3_auth_method }}" + SNMPV3AuthKey: "{{ snmpv3_auth_passphrase }}" + SNMPV3AuthKeyIsPwd: True + SNMPV3PrivMethod: "{{ snmpv3_priv_method }}" + SNMPV3PrivKey: "{{ snmpv3_priv_passphrase] }}" + SNMPV3PrivKeyIsPwd: True delegate_to: localhost ''' @@ -110,12 +112,6 @@ requests.packages.urllib3.disable_warnings() -def properties_need_update(current_node, desired_properties): - for key, value in desired_properties.items(): - if key not in current_node or current_node[key] != value: - return True - return False - def main(): argument_spec = orion_argument_spec argument_spec.update( @@ -137,21 +133,13 @@ def main(): module.fail_json(skipped=True, msg='Node not found') changed = False - - # fields to be updated might not necessarily be in the limited scope of orion.get_node(), so we specifically get them here - fields = "NodeID, " - for key in module.params['properties']: - fields += "{0}, ".format(key) - query = "SELECT {0} FROM Orion.Nodes WHERE NodeID = '{1}'".format(fields, node['node_id']) - current_node_properties = orion.swis_query(query) try: if module.check_mode: changed = True else: - if properties_need_update(current_node_properties, module.params['properties']): - orion.swis.update(node['uri'], **module.params['properties']) - changed = True + orion.swis.update(node['uri'], **module.params['properties']) + changed = True except Exception as OrionException: module.fail_json(msg='Failed to update {0}'.format(str(OrionException))) From 62614abacd8b64617ae66f3b35c4ea1b8646f6f0 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Sat, 3 Aug 2024 01:31:00 -0500 Subject: [PATCH 21/40] bump version in galaxy.yml --- galaxy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/galaxy.yml b/galaxy.yml index ba6eced..e548ce7 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -1,7 +1,7 @@ --- namespace: solarwinds name: orion -version: 2.0.0 +version: 2.1.0 readme: README.md authors: - Josh Eisenbath (@jeisenbath) From f0d04104ad5a1178a22f907c80878a51706a396b Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Sat, 3 Aug 2024 01:53:44 -0500 Subject: [PATCH 22/40] update changelog, removing fixed known issues, moving new module to a major change --- CHANGELOG.rst | 22 ++++++++++++++-------- changelogs/changelog.yaml | 14 ++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1fec83f..eeb479b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,26 +4,26 @@ Solarwinds.Orion Release Notes .. contents:: Topics + v2.1.0 ====== Release Summary --------------- -Minor Updates to orion_node and orion_update_node modules in addtion to creating orion_node_hardware_health module +Minor Updates to orion_node and orion_update_node modules in addition to creating orion_node_hardware_health module. -Minor Changes +Major Changes ------------- - Added orion_node_hardware_health module. This module allows for adding and removing hardware health sensors in Solarwinds Orion. -- Updated orion_node module to no longer require snmpv3 credential set. -- Updated orion_update_node to allow module to update monitoring from ICMP to SNMPv3. -Known Issues ------------- +Minor Changes +------------- -- orion_node_hardware_health module not idempotent -- orion_update_node updates node from icmp to snmp but gets and error when task is run again when it already is configured for snmp +- Updated orion_node module to no longer require snmpv3 credential set. +- Updated orion_update_node exmaples to show updating to SNMPv3. +- orion_node role - added tasks for new modules orion_node_ncm and orion_node_hardware_health v2.0.0 ====== @@ -111,6 +111,7 @@ Release Summary | Released 2023-12-1 + Major Changes ------------- @@ -124,6 +125,7 @@ Release Summary | Released 2023-09-26 + Major Changes ------------- @@ -142,6 +144,7 @@ Release Summary | Released 2023-08-27 + Minor Changes ------------- @@ -160,6 +163,7 @@ Release Summary | Released 2023-08-10 + Minor Changes ------------- @@ -181,6 +185,7 @@ Release Summary | Released 2023-07-14 + Minor Changes ------------- @@ -201,6 +206,7 @@ Release Summary | Released 2023-03-18 + New Modules ----------- diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index d8e579d..939b760 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -187,18 +187,16 @@ releases: release_date: '2024-04-18' 2.1.0: changes: - known_issues: - - orion_node_hardware_health module not idempotent - - orion_update_node updates node from icmp to snmp but gets and error when task - is run again when it already is configured for snmp - minor_changes: + major_changes: - Added orion_node_hardware_health module. This module allows for adding and removing hardware health sensors in Solarwinds Orion. + minor_changes: - Updated orion_node module to no longer require snmpv3 credential set. - - Updated orion_update_node to allow module to update monitoring from ICMP to - SNMPv3. + - Updated orion_update_node exmaples to show updating to SNMPv3. + - orion_node role - added tasks for new modules orion_node_ncm and orion_node_hardware_health release_summary: Minor Updates to orion_node and orion_update_node modules in - addtion to creating orion_node_hardware_health module + addition to creating orion_node_hardware_health module. fragments: - 2.1.0.yml + - role_updates.yml release_date: '2024-07-12' From df0b0d47531f51b0caee8a1323c91995cfef0b38 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Mon, 5 Aug 2024 23:52:47 -0500 Subject: [PATCH 23/40] Add last poll time to data returned from get_node(), added poll_now function --- plugins/module_utils/orion.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/module_utils/orion.py b/plugins/module_utils/orion.py index b544a34..894eff6 100644 --- a/plugins/module_utils/orion.py +++ b/plugins/module_utils/orion.py @@ -69,7 +69,7 @@ def swis_query(self, query): def get_node(self): node = {} fields = """NodeID, Caption, Unmanaged, UnManageFrom, UnManageUntil, Uri, - ObjectSubType, IP_Address, Status, StatusDescription""" + ObjectSubType, IP_Address, Status, StatusDescription, LastSystemUptimePollUtc""" if self.module.params['node_id']: results = self.swis.query( @@ -96,6 +96,7 @@ def get_node(self): node['ipaddress'] = results['results'][0]['IP_Address'] node['status'] = results['results'][0]['Status'] node['statusdescription'] = results['results'][0]['StatusDescription'] + node['lastsystemuptimepollutc'] = results['results'][0]['LastSystemUptimePollUtc'] return node def add_custom_property(self, node, prop_name, prop_value): @@ -364,3 +365,6 @@ def remove_node_from_ncm(self, node): cirrus_node_id = self.get_ncm_node(node) self.swis.invoke('Cirrus.Nodes', 'RemoveNode', cirrus_node_id) + + def poll_now(self, node): + self.swis.invoke('Orion.Nodes', 'PollNow', node['netobjectid']) From 859b7c46ea09df97b273145e0f081fe4c007a9e4 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Tue, 6 Aug 2024 02:44:51 -0500 Subject: [PATCH 24/40] add logic to trigger an snmp poll --- plugins/modules/orion_node_info.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/plugins/modules/orion_node_info.py b/plugins/modules/orion_node_info.py index c1a0429..b8e38a9 100644 --- a/plugins/modules/orion_node_info.py +++ b/plugins/modules/orion_node_info.py @@ -57,6 +57,8 @@ ''' import requests +import dateutil.parser as parser +from datetime import datetime from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec try: @@ -80,10 +82,26 @@ def main(): ) orion = OrionModule(module) + changed=False node = orion.get_node() - - module.exit_json(changed=False, orion_node=node) + if not node: + module.fail_json(skipped=True, msg='Node not found') + + # trigger poll if last poll is null or greater than 5 minutes ago + object_subtype = node['objectsubtype'] + if object_subtype == 'SNMP': + last_poll = node['lastsystemuptimepollutc'] + if not last_poll: + orion.poll_now(node) + node = orion.get_node() + elif last_poll: + time_since_poll = parser.parse(last_poll).replace(tzinfo=None) - datetime.utcnow() + if time_since_poll.seconds > 300: + orion.poll_now(node) + node = orion.get_node() + + module.exit_json(changed=changed, orion_node=node) if __name__ == "__main__": From 16b2a8bd4081cc87abb517a9c61533e79ea45eba Mon Sep 17 00:00:00 2001 From: Andrew Bailey Date: Wed, 7 Aug 2024 00:57:10 +0000 Subject: [PATCH 25/40] NCM connection profile and check mode --- plugins/module_utils/orion.py | 45 ++++++++++++++++++++++++- plugins/modules/orion_node_ncm.py | 55 +++++++++++++++++++++++++++---- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/plugins/module_utils/orion.py b/plugins/module_utils/orion.py index 894eff6..435ecdc 100644 --- a/plugins/module_utils/orion.py +++ b/plugins/module_utils/orion.py @@ -65,7 +65,12 @@ def swis_query(self, query): results = self.swis.query(query) if results['results']: return results['results'] - + + def swis_get_ncm_connection_profiles(self): + """Find all available connection profiles and return a list.""" + profile_list = self.swis.invoke('Cirrus.Nodes', 'GetAllConnectionProfiles') + return profile_list + def get_node(self): node = {} fields = """NodeID, Caption, Unmanaged, UnManageFrom, UnManageUntil, Uri, @@ -357,6 +362,44 @@ def get_ncm_node(self, node): ) if cirrus_node_query['results']: return cirrus_node_query['results'][0]['NodeID'] + def update_ncm_node_connection_profile(self, profile_dict, new_connection_profile_name, ncm_node_id): + """Retrieves an NCM node and alters its connection profile. + + Parameters + ---------- + profile_dict : dictionary + Mapping of connection profile name to its ID number + new_connection_profile_name : str + The name of the desired connection profile + ncm_node_id : GUID + The ID of the NCM node whose connection profile is being altered + + Returns + ------- + bool + A Boolean denoting success (True) or failure (False) + """ + ncmNode = self.swis.invoke('Cirrus.Nodes', 'GetNode', ncm_node_id) + if new_connection_profile_name != '-1': # the -1 denotes no connection profile is set + if new_connection_profile_name in profile_dict: + if ncmNode['ConnectionProfile'] != profile_dict[new_connection_profile_name]: + ncmNode['ConnectionProfile'] = profile_dict[new_connection_profile_name] + else: + return False + else: + raise ValueError("ValueError: Did not recognize profile name.") + else: + # set to no connection profile + if ncmNode['ConnectionProfile'] != int(new_connection_profile_name): + ncmNode['ConnectionProfile'] = int(new_connection_profile_name) + else: + return False + self.swis.invoke('Cirrus.Nodes', 'UpdateNode', ncmNode) + return True + + def get_ncm_node_object(self, ncm_node_id): + ncmNode = self.swis.invoke('Cirrus.Nodes', 'GetNode', ncm_node_id) + return ncmNode def add_node_to_ncm(self, node): self.swis.invoke('Cirrus.Nodes', 'AddNodeToNCM', node['nodeid']) diff --git a/plugins/modules/orion_node_ncm.py b/plugins/modules/orion_node_ncm.py index ee7827a..b78bfd7 100644 --- a/plugins/modules/orion_node_ncm.py +++ b/plugins/modules/orion_node_ncm.py @@ -24,6 +24,11 @@ choices: - present - absent + profile_name: + description: + - Connection Profile Name Predefined on Orion NCM. + required: false + type: str extends_documentation_fragment: - solarwinds.orion.orion_auth_options - solarwinds.orion.orion_node_options @@ -42,6 +47,7 @@ password: "{{ solarwinds_pass }}" name: "{{ node_name }}" state: present + profile_name: "{{ profile_name }}" delegate_to: localhost ''' @@ -80,12 +86,26 @@ requests.packages.urllib3.disable_warnings() +def index_connection_profiles(orion_module): + """Takes an Orion module object and enumerates all available connection profiles for later use. Returns a dictionary.""" + profile_list = orion_module.swis_get_ncm_connection_profiles() + profile_dict = {} + for k in range(0, len(profile_list)): + profile_name = profile_list[k]['Name'] + profile_id = profile_list[k]['ID'] + # create a mapping between the profile name (i.e. "Juniper_NCM") and the back-end numeric ID number + profile_dict.update({profile_name:profile_id}) + return profile_dict def main(): + # start with generic Orion arguments argument_spec = orion_argument_spec + # add desired fields to list of module arguments argument_spec.update( state=dict(required=True, choices=['present', 'absent']), + profile_name=dict(default=-1), # required field unless user wants to unset a connection profile ) + # initialize the custom Ansible module module = AnsibleModule( argument_spec, supports_check_mode=True, @@ -94,30 +114,52 @@ def main(): if not HAS_ORION: module.fail_json(msg='orionsdk required for this module') + # create an OrionModule object using our custom Ansible module orion = OrionModule(module) - + node = orion.get_node() if not node: + # if get_node() returns None, there's no node module.fail_json(skipped=True, msg='Node not found') if module.params['state'] == 'present': try: ncm_node = orion.get_ncm_node(node) if ncm_node: - module.exit_json(changed=False, orion_node=node) - else: + profile_dict = index_connection_profiles(orion) if module.check_mode: + if orion.get_ncm_node_object(ncm_node)['ConnectionProfile'] != profile_dict[module.params['profile_name']]: + module.exit_json(changed=True, orion_node=node, msg="Check mode: no changes made.") + else: + module.exit_json(changed=False, orion_node=node) + was_changed = orion.update_ncm_node_connection_profile(profile_dict, module.params['profile_name'], ncm_node) + if was_changed: module.exit_json(changed=True, orion_node=node) else: + module.exit_json(changed=False, orion_node=node) + else: + # if the node is not already in NCM, add the node and update the connection profile + if module.check_mode: + module.exit_json(changed=False, orion_node=node) + else: + # add the node to NCM orion.add_node_to_ncm(node) + # collect the NCM node ID of the node + ncm_node = orion.get_ncm_node(node) + profile_dict = index_connection_profiles(orion) + # update the connection profile + was_changed = orion.update_ncm_node_connection_profile(profile_dict, module.params['profile_name'], ncm_node) module.exit_json(changed=True, orion_node=node) + if was_changed: + module.exit_json(changed=True, orion_node=node) + else: + module.exit_json(changed=False, orion_node=node) except Exception as OrionException: - module.fail_json(msg='Failed to add node to NCM: {0}'.format(OrionException)) + module.fail_json(msg='Failed to add or update node in NCM: {0}'.format(OrionException)) elif module.params['state'] == 'absent': try: ncm_node = orion.get_ncm_node(node) - if ncm_node: if module.check_mode: module.exit_json(changed=True, orion_node=node) @@ -131,6 +173,5 @@ def main(): module.exit_json(changed=False) - if __name__ == "__main__": - main() + main() \ No newline at end of file From a3fc1db070ac47e9cb87c1f8ec47ead060ad7a15 Mon Sep 17 00:00:00 2001 From: Andrew Bailey Date: Fri, 9 Aug 2024 16:45:20 +0000 Subject: [PATCH 26/40] Fixed module name in example --- plugins/modules/orion_node_hardware_health.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index c4e5d5d..ba4def0 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -34,7 +34,7 @@ EXAMPLES = r''' --- - name: Enable hardware health polling on Cisco node - solarwinds.orion.orion_manage_hardware_health: + solarwinds.orion.orion_node_hardware_health: hostname: "server" username: "admin" password: "pass" @@ -44,7 +44,7 @@ delegate_to: localhost - name: Disable hardware health polling on Juniper node - solarwinds.orion.orion_manage_hardware_health: + solarwinds.orion.orion_node_hardware_health: hostname: "server" username: "admin" password: "pass" From 4bc31e84bc0adf6ac40d6d902ed467517930a14e Mon Sep 17 00:00:00 2001 From: Andrew Bailey Date: Fri, 16 Aug 2024 17:22:03 +0000 Subject: [PATCH 27/40] created interface info module to get currently monitored interfaces --- plugins/modules/orion_node_interface_info.py | 133 +++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 plugins/modules/orion_node_interface_info.py diff --git a/plugins/modules/orion_node_interface_info.py b/plugins/modules/orion_node_interface_info.py new file mode 100644 index 0000000..b03509b --- /dev/null +++ b/plugins/modules/orion_node_interface_info.py @@ -0,0 +1,133 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2024, Your Name +# 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: orion_node_interface_info +short_description: Get info about interfaces on Nodes in Solarwinds Orion NPM +description: + - Retrieve information about interfaces on a Node in Orion NPM that are currently being monitored. + - Provides details such as interface name, status, and other relevant attributes. +version_added: "1.0.0" +author: "Your Name" +extends_documentation_fragment: + - solarwinds.orion.orion_auth_options + - solarwinds.orion.orion_node_options +requirements: + - orionsdk + - requests +''' + +EXAMPLES = r''' +--- + +- name: Get info about all interfaces on a node + solarwinds.orion.orion_node_interface_info: + hostname: "{{ solarwinds_server }}" + username: "{{ solarwinds_user }}" + password: "{{ solarwinds_pass }}" + name: "{{ inventory_hostname }}" + delegate_to: localhost + +''' + +RETURN = r''' +orion_node: + description: Info about an Orion node. + returned: always + type: dict + sample: { + "caption": "localhost", + "ipaddress": "127.0.0.1", + "netobjectid": "N:12345", + "nodeid": "12345", + "objectsubtype": "SNMP", + "status": 1, + "statusdescription": "Node status is Up.", + "unmanaged": false, + "unmanagefrom": "1899-12-30T00:00:00+00:00", + "unmanageuntil": "1899-12-30T00:00:00+00:00", + "uri": "swis://host.domain.com/Orion/Orion.Nodes/NodeID=12345" + } +interfaces: + description: List of interfaces currently monitored on the node. + returned: always + type: list + elements: dict + sample: [ + { + "Name": "eth0", + "InterfaceID": 268420, + "AdminStatus": 1, + "OperStatus": 1, + "Speed": 1000000000.0, + "Type": 6, + "Status": 1, + "StatusDescription": "Up" + }, + { + "Name": "eth1", + "InterfaceID": 268421, + "AdminStatus": 2, + "OperStatus": 2, + "Speed": 1000000000.0, + "Type": 6, + "Status": 0, + "StatusDescription": "Unknown" + } + ] +''' + +import requests +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + import orionsdk + from orionsdk import SwisClient + HAS_ORION = True +except ImportError: + HAS_ORION = False +except Exception: + raise Exception + +requests.packages.urllib3.disable_warnings() + + +def main(): + argument_spec = orion_argument_spec + module = AnsibleModule( + argument_spec, + supports_check_mode=True, + required_one_of=[('name', 'node_id', 'ip_address')], + ) + + if not HAS_ORION: + module.fail_json(msg='orionsdk required for this module') + + orion = OrionModule(module) + + node = orion.get_node() + if not node: + module.fail_json(skipped=True, msg='Node not found') + + interfaces = [] + try: + interface_query = orion.swis.query( + "SELECT Caption, Name, InterfaceID, AdminStatus, OperStatus, Speed, Type, Status, StatusDescription " + "FROM Orion.NPM.Interfaces WHERE NodeID = '{0}'".format(node['nodeid']) + ) + interfaces = interface_query['results'] + except Exception as e: + module.fail_json(msg="Failed to retrieve interfaces: {0}".format(str(e))) + + module.exit_json(changed=False, orion_node=node, interfaces=interfaces) + + +if __name__ == "__main__": + main() From f1d106648a52b5d2c7b2ce992b7a38d92a9c974e Mon Sep 17 00:00:00 2001 From: Andrew Bailey Date: Fri, 16 Aug 2024 18:01:40 +0000 Subject: [PATCH 28/40] Changed interface Caption To Name --- plugins/modules/orion_node_interface.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/modules/orion_node_interface.py b/plugins/modules/orion_node_interface.py index c8ed3af..36131d3 100644 --- a/plugins/modules/orion_node_interface.py +++ b/plugins/modules/orion_node_interface.py @@ -105,7 +105,7 @@ elements: dict sample: [ { - "Caption": "lo", + "Name": "lo", "InterfaceID": 0, "Manageable": true, "ifAdminStatus": 1, @@ -116,7 +116,7 @@ "ifType": 24 }, { - "Caption": "eth0", + "Name": "eth0", "InterfaceID": 268420, "Manageable": true, "ifAdminStatus": 1, @@ -134,7 +134,7 @@ elements: dict sample: [ { - "Caption": "lo", + "Name": "lo", "InterfaceID": 0, "Manageable": true, "ifAdminStatus": 1, @@ -191,11 +191,11 @@ def main(): try: if not module.params['interface']: for interface in discovered: - if not orion.get_interface(node, interface['Caption']): + if not orion.get_interface(node, interface['Name']): changed = True interfaces.append(interface) if not module.check_mode: - orion.add_interface(node, interface['Caption'], False, discovered) + orion.add_interface(node, interface['Name'], False, discovered) else: get_int = orion.get_interface(node, module.params['interface']) if not get_int: @@ -211,11 +211,11 @@ def main(): try: if not module.params['interface']: for interface in discovered: - if orion.get_interface(node, interface['Caption']): + if orion.get_interface(node, interface['Name']): changed = True interfaces.append(interface) if not module.check_mode: - orion.remove_interface(node, interface['Caption']) + orion.remove_interface(node, interface['Name']) else: get_int = orion.get_interface(node, module.params['interface']) if get_int: From b6b219c4baa99bc48ab5fb8592912241b87e6ed8 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Sun, 1 Sep 2024 13:37:11 -0500 Subject: [PATCH 29/40] default value match value type --- plugins/doc_fragments/orion_auth_options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/doc_fragments/orion_auth_options.py b/plugins/doc_fragments/orion_auth_options.py index 750b5ff..a32b3da 100644 --- a/plugins/doc_fragments/orion_auth_options.py +++ b/plugins/doc_fragments/orion_auth_options.py @@ -35,7 +35,7 @@ class ModuleDocFragment(object): - This argument was introduced in orionsdk 0.4.0 to support connecting to either API. - If using an older version of Solarwinds with orionsdk 0.4.0, define this as port 17778. required: false - default: 17774 + default: '17774' type: str verify: description: From 50ea4a7df0b79e1e904e68ad0ec3f58b767014af Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Sun, 1 Sep 2024 17:35:02 -0500 Subject: [PATCH 30/40] fixing ansible-test sanity errors --- plugins/module_utils/orion.py | 34 ++++++++++++---- plugins/modules/orion_custom_property.py | 15 ++++--- plugins/modules/orion_node.py | 39 +++++++++++-------- plugins/modules/orion_node_application.py | 13 +++++-- plugins/modules/orion_node_custom_poller.py | 11 ++++-- plugins/modules/orion_node_hardware_health.py | 37 ++++++++++++++++-- plugins/modules/orion_node_info.py | 25 ++++++++---- plugins/modules/orion_node_interface.py | 13 +++++-- plugins/modules/orion_node_interface_info.py | 14 ++++--- plugins/modules/orion_node_ncm.py | 19 ++++++--- plugins/modules/orion_node_poller.py | 13 +++++-- plugins/modules/orion_node_poller_info.py | 15 ++++--- plugins/modules/orion_query.py | 30 ++++++++------ plugins/modules/orion_update_node.py | 12 ++++-- plugins/modules/orion_volume.py | 11 ++++-- plugins/modules/orion_volume_info.py | 11 ++++-- 16 files changed, 223 insertions(+), 89 deletions(-) diff --git a/plugins/module_utils/orion.py b/plugins/module_utils/orion.py index 435ecdc..074fc9d 100644 --- a/plugins/module_utils/orion.py +++ b/plugins/module_utils/orion.py @@ -7,9 +7,26 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -from dateutil.parser import parse +from ansible.module_utils.six import raise_from import re -from distutils.version import LooseVersion +try: + from ansible.module_utils.compat.version import LooseVersion # noqa: F401 +except ImportError: + try: + from distutils.version import LooseVersion # noqa: F401 + except ImportError as exc: + raise_from(ImportError('To use this plugin or module with ansible-core' + ' < 2.11, you need to use Python < 3.12 with ' + 'distutils.version present'), exc) + +try: + from dateutil.parser import parse + HAS_DATEUTIL = True +except ImportError: + HAS_DATEUTIL = False +except Exception: + raise Exception + try: import orionsdk from orionsdk import SwisClient @@ -25,7 +42,7 @@ username=dict(required=True, no_log=True), password=dict(required=True, no_log=True), port=dict(required=False, type='str', default='17774'), - verify=dict(required=False, type=bool, default=False), + verify=dict(required=False, type='bool', default=False), node_id=dict(required=False), ip_address=dict(required=False), name=dict(required=False, aliases=['caption']), @@ -65,12 +82,12 @@ def swis_query(self, query): results = self.swis.query(query) if results['results']: return results['results'] - + def swis_get_ncm_connection_profiles(self): """Find all available connection profiles and return a list.""" profile_list = self.swis.invoke('Cirrus.Nodes', 'GetAllConnectionProfiles') return profile_list - + def get_node(self): node = {} fields = """NodeID, Caption, Unmanaged, UnManageFrom, UnManageUntil, Uri, @@ -360,6 +377,7 @@ def get_ncm_node(self, node): cirrus_node_query = self.swis.query( "SELECT NodeID from Cirrus.Nodes WHERE CoreNodeID = '{0}'".format(node['nodeid']) ) + if cirrus_node_query['results']: return cirrus_node_query['results'][0]['NodeID'] def update_ncm_node_connection_profile(self, profile_dict, new_connection_profile_name, ncm_node_id): @@ -373,14 +391,14 @@ def update_ncm_node_connection_profile(self, profile_dict, new_connection_profil The name of the desired connection profile ncm_node_id : GUID The ID of the NCM node whose connection profile is being altered - + Returns ------- bool A Boolean denoting success (True) or failure (False) """ ncmNode = self.swis.invoke('Cirrus.Nodes', 'GetNode', ncm_node_id) - if new_connection_profile_name != '-1': # the -1 denotes no connection profile is set + if new_connection_profile_name != '-1': # the -1 denotes no connection profile is set if new_connection_profile_name in profile_dict: if ncmNode['ConnectionProfile'] != profile_dict[new_connection_profile_name]: ncmNode['ConnectionProfile'] = profile_dict[new_connection_profile_name] @@ -396,7 +414,7 @@ def update_ncm_node_connection_profile(self, profile_dict, new_connection_profil return False self.swis.invoke('Cirrus.Nodes', 'UpdateNode', ncmNode) return True - + def get_ncm_node_object(self, ncm_node_id): ncmNode = self.swis.invoke('Cirrus.Nodes', 'GetNode', ncm_node_id) return ncmNode diff --git a/plugins/modules/orion_custom_property.py b/plugins/modules/orion_custom_property.py index 9f9d5f4..1075ef0 100644 --- a/plugins/modules/orion_custom_property.py +++ b/plugins/modules/orion_custom_property.py @@ -81,9 +81,16 @@ } ''' -import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient @@ -93,8 +100,6 @@ except Exception: raise Exception -requests.packages.urllib3.disable_warnings() - def main(): argument_spec = orion_argument_spec @@ -133,7 +138,7 @@ def main(): orion.add_custom_property(node, module.params['property_name'], module.params['property_value']) module.exit_json(changed=True, orion_node=node) except Exception as OrionException: - module.fail_json(msg='Failed to add custom properties: {}'.format(OrionException)) + module.fail_json(msg='Failed to add custom properties: {0}'.format(OrionException)) elif module.params['state'] == 'absent': try: prop_name, prop_value = orion.get_node_custom_property_value(node, module.params['property_name']) @@ -146,7 +151,7 @@ def main(): else: module.exit_json(changed=False, orion_node=node) except Exception as OrionException: - module.fail_json(msg='Failed to remove custom property from node: {}'.format(OrionException)) + module.fail_json(msg='Failed to remove custom property from node: {0}'.format(OrionException)) # TODO create/update custom properties and their values within solarwinds? module.exit_json(changed=False) diff --git a/plugins/modules/orion_node.py b/plugins/modules/orion_node.py index 9b5828f..0863576 100644 --- a/plugins/modules/orion_node.py +++ b/plugins/modules/orion_node.py @@ -127,7 +127,6 @@ - Authentication method for SNMPv3. - Required when SNMP version is 3. type: str - default: SHA1 choices: - SHA1 - MD5 @@ -143,13 +142,11 @@ - SNMPv3 Authentication Password is a key. - Confusingly, value of True corresponds to web GUI checkbox being unchecked. type: bool - default: True required: false snmpv3_priv_method: description: - Privacy method for SNMPv3. type: str - default: AES128 choices: - DES56 - AES128 @@ -166,7 +163,6 @@ - SNMPv3 Privacy Password is a key. - Confusingly, value of True corresponds to web GUI checkbox being unchecked. type: bool - default: True required: false wmi_credential_set: description: @@ -235,10 +231,23 @@ } ''' -from datetime import datetime, timedelta -import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + from datetime import datetime, timedelta + HAS_DATETIME = True +except ImportError: + HAS_DATETIME = False +except Exception: + raise Exception +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient @@ -248,8 +257,6 @@ except Exception: raise Exception -requests.packages.urllib3.disable_warnings() - def add_credential_set(node, credential_set_name, credential_set_type): credential_set_type_valid = ['WMICredential', 'ROSNMPCredentialID', 'RWSNMPCredentialID'] @@ -469,14 +476,14 @@ def main(): ro_community_string=dict(required=False, no_log=True), rw_community_string=dict(required=False, no_log=True), snmp_version=dict(required=False, default=None, choices=['2', '3']), - snmpv3_credential_set=dict(required=False, default=None, type=str), - snmpv3_username=dict(required=False, type=str), - snmpv3_auth_method=dict(required=False, type=str, choices=['SHA1', 'MD5']), - snmpv3_auth_key=dict(required=False, type=str, no_log=True), - snmpv3_auth_key_is_pwd=dict(required=False, type=bool), - snmpv3_priv_method=dict(required=False, type=str, choices=['DES56', 'AES128', 'AES192', 'AES256']), - snmpv3_priv_key=dict(required=False, type=str, no_log=True), - snmpv3_priv_key_is_pwd=dict(required=False, type=bool), + snmpv3_credential_set=dict(required=False, default=None, type='str'), + snmpv3_username=dict(required=False, type='str'), + snmpv3_auth_method=dict(required=False, type='str', choices=['SHA1', 'MD5']), + snmpv3_auth_key=dict(required=False, type='str', no_log=True), + snmpv3_auth_key_is_pwd=dict(required=False, type='bool'), + snmpv3_priv_method=dict(required=False, type='str', choices=['DES56', 'AES128', 'AES192', 'AES256']), + snmpv3_priv_key=dict(required=False, type='str', no_log=True), + snmpv3_priv_key_is_pwd=dict(required=False, type='bool'), snmp_port=dict(required=False, default='161'), snmp_allow_64=dict(required=False, default=True, type='bool'), wmi_credential_set=dict(required=False, no_log=True), diff --git a/plugins/modules/orion_node_application.py b/plugins/modules/orion_node_application.py index 872d28c..1cd8000 100644 --- a/plugins/modules/orion_node_application.py +++ b/plugins/modules/orion_node_application.py @@ -86,9 +86,16 @@ } ''' -import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient @@ -98,8 +105,6 @@ except Exception: raise Exception -requests.packages.urllib3.disable_warnings() - def main(): argument_spec = orion_argument_spec @@ -114,7 +119,7 @@ def main(): supports_check_mode=True, required_one_of=[('name', 'node_id', 'ip_address')], ) - + if not HAS_ORION: module.fail_json(msg='orionsdk required for this module') diff --git a/plugins/modules/orion_node_custom_poller.py b/plugins/modules/orion_node_custom_poller.py index 55d1d3e..650556f 100644 --- a/plugins/modules/orion_node_custom_poller.py +++ b/plugins/modules/orion_node_custom_poller.py @@ -74,9 +74,16 @@ } ''' -import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient @@ -86,8 +93,6 @@ except Exception: raise Exception -requests.packages.urllib3.disable_warnings() - def main(): argument_spec = orion_argument_spec diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index ba4def0..eeb10e7 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -1,6 +1,8 @@ #!/usr/bin/python # -*- coding: utf-8 -*- +# 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 @@ -17,8 +19,28 @@ polling_method: description: - The polling method to be used for hardware health. - required: True when state is present - choices: ['Unknown', 'VMware', 'SnmpDell', 'SnmpHP', 'SnmpIBM', 'VMwareAPI', 'WmiDell', 'WmiHP', 'WmiIBM', 'SnmpCisco', 'SnmpJuniper', 'SnmpNPMHP', 'SnmpF5', 'SnmpDellPowerEdge', 'SnmpDellPowerConnect', 'SnmpDellBladeChassis', 'SnmpHPBladeChassis', 'Forwarded', 'SnmpArista'] + - Required when I(state=present) + required: False + choices: + - 'Unknown' + - 'VMware' + - 'SnmpDell' + - 'SnmpHP' + - 'SnmpIBM' + - 'VMwareAPI' + - 'WmiDell' + - 'WmiHP' + - 'WmiIBM' + - 'SnmpCisco' + - 'SnmpJuniper' + - 'SnmpNPMHP' + - 'SnmpF5' + - 'SnmpDellPowerEdge' + - 'SnmpDellPowerConnect' + - 'SnmpDellBladeChassis' + - 'SnmpHPBladeChassis' + - 'Forwarded' + - 'SnmpArista' type: str state: description: @@ -75,7 +97,14 @@ from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec - +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: from orionsdk import SwisClient HAS_ORIONSDK = True @@ -105,6 +134,7 @@ 'SnmpArista': 18 } + def main(): argument_spec = orion_argument_spec argument_spec.update( @@ -153,5 +183,6 @@ def main(): module.exit_json(changed=changed, orion_node=node) + if __name__ == '__main__': main() diff --git a/plugins/modules/orion_node_info.py b/plugins/modules/orion_node_info.py index b8e38a9..78e99be 100644 --- a/plugins/modules/orion_node_info.py +++ b/plugins/modules/orion_node_info.py @@ -56,22 +56,33 @@ } ''' -import requests -import dateutil.parser as parser from datetime import datetime from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + from dateutil import parser + HAS_DATEUTIL = True +except ImportError: + HAS_DATEUTIL = False +except Exception: + raise Exception +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient HAS_ORION = True -except Exception as OrionSdkImport: +except ImportError: HAS_ORION = False except Exception: raise Exception -requests.packages.urllib3.disable_warnings() - def main(): argument_spec = orion_argument_spec @@ -82,12 +93,12 @@ def main(): ) orion = OrionModule(module) - changed=False + changed = False node = orion.get_node() if not node: module.fail_json(skipped=True, msg='Node not found') - + # trigger poll if last poll is null or greater than 5 minutes ago object_subtype = node['objectsubtype'] if object_subtype == 'SNMP': diff --git a/plugins/modules/orion_node_interface.py b/plugins/modules/orion_node_interface.py index 36131d3..72f3b83 100644 --- a/plugins/modules/orion_node_interface.py +++ b/plugins/modules/orion_node_interface.py @@ -147,9 +147,16 @@ ] ''' -import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient @@ -159,15 +166,13 @@ except Exception: raise Exception -requests.packages.urllib3.disable_warnings() - def main(): argument_spec = orion_argument_spec argument_spec.update( state=dict(required=True, choices=['present', 'absent']), interface=dict(required=False, type='str'), - regex=dict(required=False, type=bool, default=False), + regex=dict(required=False, type='bool', default=False), ) module = AnsibleModule( argument_spec, diff --git a/plugins/modules/orion_node_interface_info.py b/plugins/modules/orion_node_interface_info.py index b03509b..a68f198 100644 --- a/plugins/modules/orion_node_interface_info.py +++ b/plugins/modules/orion_node_interface_info.py @@ -1,7 +1,6 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# Copyright: (c) 2024, Your Name # 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 @@ -15,7 +14,7 @@ - Retrieve information about interfaces on a Node in Orion NPM that are currently being monitored. - Provides details such as interface name, status, and other relevant attributes. version_added: "1.0.0" -author: "Your Name" +author: "Andrew Bailey (@Andyjb8)" extends_documentation_fragment: - solarwinds.orion.orion_auth_options - solarwinds.orion.orion_node_options @@ -84,9 +83,16 @@ ] ''' -import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient @@ -96,8 +102,6 @@ except Exception: raise Exception -requests.packages.urllib3.disable_warnings() - def main(): argument_spec = orion_argument_spec diff --git a/plugins/modules/orion_node_ncm.py b/plugins/modules/orion_node_ncm.py index b78bfd7..1b6017e 100644 --- a/plugins/modules/orion_node_ncm.py +++ b/plugins/modules/orion_node_ncm.py @@ -27,6 +27,7 @@ profile_name: description: - Connection Profile Name Predefined on Orion NCM. + default: '-1' required: false type: str extends_documentation_fragment: @@ -72,9 +73,16 @@ } ''' -import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient @@ -84,7 +92,6 @@ except Exception: raise Exception -requests.packages.urllib3.disable_warnings() def index_connection_profiles(orion_module): """Takes an Orion module object and enumerates all available connection profiles for later use. Returns a dictionary.""" @@ -94,16 +101,17 @@ def index_connection_profiles(orion_module): profile_name = profile_list[k]['Name'] profile_id = profile_list[k]['ID'] # create a mapping between the profile name (i.e. "Juniper_NCM") and the back-end numeric ID number - profile_dict.update({profile_name:profile_id}) + profile_dict.update({profile_name: profile_id}) return profile_dict + def main(): # start with generic Orion arguments argument_spec = orion_argument_spec # add desired fields to list of module arguments argument_spec.update( state=dict(required=True, choices=['present', 'absent']), - profile_name=dict(default=-1), # required field unless user wants to unset a connection profile + profile_name=dict(required=False, type='str', default='-1'), # required field unless user wants to unset a connection profile ) # initialize the custom Ansible module module = AnsibleModule( @@ -116,7 +124,7 @@ def main(): # create an OrionModule object using our custom Ansible module orion = OrionModule(module) - + node = orion.get_node() if not node: # if get_node() returns None, there's no node @@ -173,5 +181,6 @@ def main(): module.exit_json(changed=False) + if __name__ == "__main__": main() \ No newline at end of file diff --git a/plugins/modules/orion_node_poller.py b/plugins/modules/orion_node_poller.py index 69b1280..c6fcb65 100644 --- a/plugins/modules/orion_node_poller.py +++ b/plugins/modules/orion_node_poller.py @@ -105,9 +105,16 @@ } ''' -import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient @@ -117,8 +124,6 @@ except Exception: raise Exception -requests.packages.urllib3.disable_warnings() - def main(): argument_spec = orion_argument_spec @@ -132,7 +137,7 @@ def main(): supports_check_mode=True, required_one_of=[('name', 'node_id', 'ip_address')], ) - + if not HAS_ORION: module.fail_json(msg='orionsdk required for this module') diff --git a/plugins/modules/orion_node_poller_info.py b/plugins/modules/orion_node_poller_info.py index 95bbfa1..ddb5db8 100644 --- a/plugins/modules/orion_node_poller_info.py +++ b/plugins/modules/orion_node_poller_info.py @@ -34,10 +34,10 @@ name: "{{ node_name }}" delegate_to: localhost register: poller_info - + - name: Loop through the pollers and show PollerType and Enabled ansible.builtin.debug: - msg: "{{ item.PollerType }}": "{{ item.Enabled }}" + msg: "{{ item.PollerType }}: {{ item.Enabled }}" loop: "{{ poller_info.pollers }}" ''' @@ -85,9 +85,16 @@ ] ''' -import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient @@ -97,8 +104,6 @@ except Exception: raise Exception -requests.packages.urllib3.disable_warnings() - def main(): argument_spec = orion_argument_spec diff --git a/plugins/modules/orion_query.py b/plugins/modules/orion_query.py index 4a682fc..c544756 100644 --- a/plugins/modules/orion_query.py +++ b/plugins/modules/orion_query.py @@ -17,7 +17,7 @@ - "Will return the query results as a json object, which can be registered and used with other modules." - "Optionally can also save results into a csv." version_added: "1.3.0" -author: +author: - "Josh M. Eisenbath (@jeisenbath)" - "Andrew Bailey (@andyjb8)" options: @@ -73,18 +73,24 @@ "IP_Address": "127.0.0.1", "MachineType": "net-snmp - Linux", "NodeID": 12345, - "StatusIcon": "Up.gif ", + "StatusIcon": "Up.gif", "Vendor": "net-snmp" } ] ''' -import requests import csv from ansible.module_utils.basic import AnsibleModule -from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec - +from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient @@ -94,8 +100,6 @@ except Exception: raise Exception -requests.packages.urllib3.disable_warnings() - def write_to_csv(nodes, csv_file_path): headers = nodes[0].keys() if nodes else [] @@ -107,10 +111,14 @@ def write_to_csv(nodes, csv_file_path): def main(): - argument_spec = orion_argument_spec - argument_spec.update( - query=dict(required=True, type=str), - csv_path=dict(required=False, type=str), + argument_spec = dict( + hostname=dict(required=True), + username=dict(required=True, no_log=True), + password=dict(required=True, no_log=True), + port=dict(required=False, type='str', default='17774'), + verify=dict(required=False, type='bool', default=False), + query=dict(required=True, type='str'), + csv_path=dict(required=False, type='str'), ) module = AnsibleModule( argument_spec, diff --git a/plugins/modules/orion_update_node.py b/plugins/modules/orion_update_node.py index c85bcd1..d86bb7b 100644 --- a/plugins/modules/orion_update_node.py +++ b/plugins/modules/orion_update_node.py @@ -98,9 +98,16 @@ } ''' -import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient @@ -110,7 +117,6 @@ except Exception: raise Exception -requests.packages.urllib3.disable_warnings() def main(): argument_spec = orion_argument_spec @@ -127,7 +133,7 @@ def main(): module.fail_json(msg='orionsdk required for this module') orion = OrionModule(module) - + node = orion.get_node() if not node: module.fail_json(skipped=True, msg='Node not found') diff --git a/plugins/modules/orion_volume.py b/plugins/modules/orion_volume.py index a30cc3a..d8459d7 100644 --- a/plugins/modules/orion_volume.py +++ b/plugins/modules/orion_volume.py @@ -130,9 +130,16 @@ } ''' -import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient @@ -142,8 +149,6 @@ except Exception: raise Exception -requests.packages.urllib3.disable_warnings() - def main(): argument_spec = orion_argument_spec diff --git a/plugins/modules/orion_volume_info.py b/plugins/modules/orion_volume_info.py index 3b8ce57..a344b39 100644 --- a/plugins/modules/orion_volume_info.py +++ b/plugins/modules/orion_volume_info.py @@ -87,9 +87,16 @@ } ''' -import requests from ansible.module_utils.basic import AnsibleModule from ansible_collections.solarwinds.orion.plugins.module_utils.orion import OrionModule, orion_argument_spec +try: + import requests + HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() +except ImportError: + HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk from orionsdk import SwisClient @@ -99,8 +106,6 @@ except Exception: raise Exception -requests.packages.urllib3.disable_warnings() - def main(): argument_spec = orion_argument_spec From 2c32f70ec4395c833e2dc48a1b911005d920b90c Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Sun, 1 Sep 2024 18:06:24 -0500 Subject: [PATCH 31/40] fixing more ansible-test sanity errors --- plugins/inventory/orion_nodes_inventory.py | 38 ++++++++++++------- plugins/module_utils/orion.py | 1 + plugins/modules/orion_node_hardware_health.py | 2 +- plugins/modules/orion_node_ncm.py | 2 +- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/plugins/inventory/orion_nodes_inventory.py b/plugins/inventory/orion_nodes_inventory.py index 3bbdcb3..35e284a 100644 --- a/plugins/inventory/orion_nodes_inventory.py +++ b/plugins/inventory/orion_nodes_inventory.py @@ -24,7 +24,7 @@ description: Hostname of the Solarwinds Orion server. required: true type: string - orion_username: + orion_username: description: Username to Authenticate with Orion Server. required: true type: string @@ -36,7 +36,7 @@ description: Port to connect to the Solarwinds Information Service API. Only supported if orionsdk >= 0.4.0 required: false type: string - default: 17774 + default: '17774' verify: description: Verify SSL Certificate for Solarwinds Information Service API. required: false @@ -57,7 +57,7 @@ hostvar_prefix: description: - String to prepend to the Orion.Nodes field name when converting to a host variable. - - E.g. 'hostvar_prefix: orion_' generates variables like 'orion_dns', 'orion_ip_address', 'orion_caption' + - For example I(hostvar_prefix=orion_) generates variables like orion_dns, orion_ip_address, orion_caption required: false type: string default: orion_ @@ -78,7 +78,7 @@ # It will use that DNS field as the host name # It will also pull in IP_Address and Caption to create host variables from # In this environment, the nodes have a custom property 'Server_Environment' -# So we will pull that value and create a host variable, +# So we will pull that value and create a host variable, # then use the keyed_groups function to turn that value into a group. --- plugin: solarwinds.orion.orion_nodes_inventory @@ -104,7 +104,16 @@ from ansible.module_utils._text import to_text, to_native from ansible.utils.display import Display from ansible.parsing.yaml.objects import AnsibleVaultEncryptedUnicode -from distutils.version import LooseVersion +from ansible.module_utils.six import raise_from +try: + from ansible.module_utils.compat.version import LooseVersion # noqa: F401 +except ImportError: + try: + from distutils.version import LooseVersion # noqa: F401 + except ImportError as exc: + raise_from(ImportError('To use this plugin or module with ansible-core' + ' < 2.11, you need to use Python < 3.12 with ' + 'distutils.version present'), exc) display = Display() @@ -112,8 +121,11 @@ try: import requests HAS_REQUESTS = True + requests.packages.urllib3.disable_warnings() except ImportError: HAS_REQUESTS = False +except Exception: + raise Exception try: import orionsdk @@ -124,8 +136,6 @@ from ansible.plugins.inventory import BaseInventoryPlugin, Cacheable, Constructable -requests.packages.urllib3.disable_warnings() - class InventoryModule(BaseInventoryPlugin, Cacheable, Constructable): NAME = 'solarwinds.orion.orion_nodes_inventory' @@ -155,7 +165,7 @@ def parse(self, inventory, loader, path, cache=True): self._consume_options(config) except Exception as e: - raise AnsibleParserError('Failed to consume options: {}'.format(to_native(e))) + raise AnsibleParserError('Failed to consume options: {0}'.format(to_native(e))) cache_key = self.get_cache_key(path) update_cache = False @@ -210,7 +220,7 @@ def _populate_from_source(self): inv_hostvars[node_hostname] = node_hostvars self.add_host(node_hostname, node_hostvars) except Exception as e: - raise AnsibleParserError('Error iterating over query results: {}'.format(to_native(e))) + raise AnsibleParserError('Error iterating over query results: {0}'.format(to_native(e))) return inv_hostvars @@ -236,15 +246,15 @@ def get_orion_nodes(self): __SWIS__ = SwisClient(**swis_options) __SWIS__.query('SELECT uri FROM Orion.Environment') except Exception as e: - raise AnsibleError('Failed to connect to Orion database: {}'.format(to_native(e))) + raise AnsibleError('Failed to connect to Orion database: {0}'.format(to_native(e))) hostname_field = self.get_option('hostname_field') - select_string = "SELECT NodeID, node.{}".format(hostname_field) + select_string = "SELECT NodeID, node.{0}".format(hostname_field) for hostvar_field in self.get_option('hostvar_fields'): - select_string = select_string + ", node.{}".format(hostvar_field) + select_string = select_string + ", node.{0}".format(hostvar_field) if self.get_option('hostvar_custom_properties'): for custom_property in self.get_option('hostvar_custom_properties'): - select_string = select_string + ", custom.{0} as {1}".format(custom_property, custom_property) + select_string = select_string + ", custom.{0} as {0}".format(custom_property) query = "{0} FROM Orion.Nodes as node ".format(select_string) @@ -254,7 +264,7 @@ def get_orion_nodes(self): if self.get_option('filter'): query = query + self.get_option('filter') - self.display.vvv('Using query "{}"'.format(to_text(query))) + self.display.vvv('Using query "{0}"'.format(to_text(query))) results = __SWIS__.query(query) diff --git a/plugins/module_utils/orion.py b/plugins/module_utils/orion.py index 074fc9d..464c4b0 100644 --- a/plugins/module_utils/orion.py +++ b/plugins/module_utils/orion.py @@ -380,6 +380,7 @@ def get_ncm_node(self, node): if cirrus_node_query['results']: return cirrus_node_query['results'][0]['NodeID'] + def update_ncm_node_connection_profile(self, profile_dict, new_connection_profile_name, ncm_node_id): """Retrieves an NCM node and alters its connection profile. diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index eeb10e7..5b1dbac 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -21,7 +21,7 @@ - The polling method to be used for hardware health. - Required when I(state=present) required: False - choices: + choices: - 'Unknown' - 'VMware' - 'SnmpDell' diff --git a/plugins/modules/orion_node_ncm.py b/plugins/modules/orion_node_ncm.py index 1b6017e..eb4bcbb 100644 --- a/plugins/modules/orion_node_ncm.py +++ b/plugins/modules/orion_node_ncm.py @@ -183,4 +183,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() From ab0e740a3804c248a23185d963d84895ba174992 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Sun, 1 Sep 2024 18:30:28 -0500 Subject: [PATCH 32/40] importing orion_argument_spec as a function apparently stops params 'leaking' to other modules during sanity test --- plugins/module_utils/orion.py | 21 ++++++++++--------- plugins/modules/orion_custom_property.py | 2 +- plugins/modules/orion_node.py | 2 +- plugins/modules/orion_node_application.py | 2 +- plugins/modules/orion_node_custom_poller.py | 2 +- plugins/modules/orion_node_hardware_health.py | 2 +- plugins/modules/orion_node_info.py | 2 +- plugins/modules/orion_node_interface.py | 2 +- plugins/modules/orion_node_interface_info.py | 2 +- plugins/modules/orion_node_ncm.py | 2 +- plugins/modules/orion_node_poller.py | 2 +- plugins/modules/orion_node_poller_info.py | 2 +- plugins/modules/orion_update_node.py | 2 +- plugins/modules/orion_volume.py | 2 +- plugins/modules/orion_volume_info.py | 2 +- 15 files changed, 25 insertions(+), 24 deletions(-) diff --git a/plugins/module_utils/orion.py b/plugins/module_utils/orion.py index 464c4b0..639104e 100644 --- a/plugins/module_utils/orion.py +++ b/plugins/module_utils/orion.py @@ -37,16 +37,17 @@ raise Exception -orion_argument_spec = dict( - hostname=dict(required=True), - username=dict(required=True, no_log=True), - password=dict(required=True, no_log=True), - port=dict(required=False, type='str', default='17774'), - verify=dict(required=False, type='bool', default=False), - node_id=dict(required=False), - ip_address=dict(required=False), - name=dict(required=False, aliases=['caption']), -) +def orion_argument_spec(): + return dict( + hostname=dict(required=True), + username=dict(required=True, no_log=True), + password=dict(required=True, no_log=True), + port=dict(required=False, type='str', default='17774'), + verify=dict(required=False, type='bool', default=False), + node_id=dict(required=False), + ip_address=dict(required=False), + name=dict(required=False, aliases=['caption']), + ) class OrionModule: diff --git a/plugins/modules/orion_custom_property.py b/plugins/modules/orion_custom_property.py index 1075ef0..4e08a2f 100644 --- a/plugins/modules/orion_custom_property.py +++ b/plugins/modules/orion_custom_property.py @@ -102,7 +102,7 @@ def main(): - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() argument_spec.update( state=dict(required=True, choices=['present', 'absent']), property_name=dict(required=True, type='str'), diff --git a/plugins/modules/orion_node.py b/plugins/modules/orion_node.py index 0863576..35b2ed3 100644 --- a/plugins/modules/orion_node.py +++ b/plugins/modules/orion_node.py @@ -467,7 +467,7 @@ def unmute_node(module, node): def main(): - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() argument_spec.update( state=dict(required=True, choices=['present', 'absent', 'managed', 'unmanaged', 'muted', 'unmuted']), unmanage_from=dict(required=False, default=None), diff --git a/plugins/modules/orion_node_application.py b/plugins/modules/orion_node_application.py index 1cd8000..328bbcc 100644 --- a/plugins/modules/orion_node_application.py +++ b/plugins/modules/orion_node_application.py @@ -107,7 +107,7 @@ def main(): - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() argument_spec.update( state=dict(required=True, choices=['present', 'absent']), application_template_name=dict(required=True, type='str'), diff --git a/plugins/modules/orion_node_custom_poller.py b/plugins/modules/orion_node_custom_poller.py index 650556f..3b38b8b 100644 --- a/plugins/modules/orion_node_custom_poller.py +++ b/plugins/modules/orion_node_custom_poller.py @@ -95,7 +95,7 @@ def main(): - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() argument_spec.update( state=dict(required=True, choices=['present', 'absent']), custom_poller=dict(required=True, type='str') diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index 5b1dbac..865c99a 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -136,7 +136,7 @@ def main(): - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() argument_spec.update( state=dict(required=True, choices=['present', 'absent']), polling_method=dict(type='str', required=False, choices=list(POLLING_METHOD_MAP.keys())), # Not required for absent state diff --git a/plugins/modules/orion_node_info.py b/plugins/modules/orion_node_info.py index 78e99be..00413f5 100644 --- a/plugins/modules/orion_node_info.py +++ b/plugins/modules/orion_node_info.py @@ -85,7 +85,7 @@ def main(): - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() module = AnsibleModule( argument_spec, supports_check_mode=True, diff --git a/plugins/modules/orion_node_interface.py b/plugins/modules/orion_node_interface.py index 72f3b83..a54ff27 100644 --- a/plugins/modules/orion_node_interface.py +++ b/plugins/modules/orion_node_interface.py @@ -168,7 +168,7 @@ def main(): - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() argument_spec.update( state=dict(required=True, choices=['present', 'absent']), interface=dict(required=False, type='str'), diff --git a/plugins/modules/orion_node_interface_info.py b/plugins/modules/orion_node_interface_info.py index a68f198..e0e1f81 100644 --- a/plugins/modules/orion_node_interface_info.py +++ b/plugins/modules/orion_node_interface_info.py @@ -104,7 +104,7 @@ def main(): - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() module = AnsibleModule( argument_spec, supports_check_mode=True, diff --git a/plugins/modules/orion_node_ncm.py b/plugins/modules/orion_node_ncm.py index eb4bcbb..635c3a2 100644 --- a/plugins/modules/orion_node_ncm.py +++ b/plugins/modules/orion_node_ncm.py @@ -107,7 +107,7 @@ def index_connection_profiles(orion_module): def main(): # start with generic Orion arguments - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() # add desired fields to list of module arguments argument_spec.update( state=dict(required=True, choices=['present', 'absent']), diff --git a/plugins/modules/orion_node_poller.py b/plugins/modules/orion_node_poller.py index c6fcb65..7124960 100644 --- a/plugins/modules/orion_node_poller.py +++ b/plugins/modules/orion_node_poller.py @@ -126,7 +126,7 @@ def main(): - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() argument_spec.update( state=dict(required=True, choices=['present', 'absent']), poller=dict(required=True, type='str'), diff --git a/plugins/modules/orion_node_poller_info.py b/plugins/modules/orion_node_poller_info.py index ddb5db8..09bf89c 100644 --- a/plugins/modules/orion_node_poller_info.py +++ b/plugins/modules/orion_node_poller_info.py @@ -106,7 +106,7 @@ def main(): - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() module = AnsibleModule( argument_spec, supports_check_mode=True, diff --git a/plugins/modules/orion_update_node.py b/plugins/modules/orion_update_node.py index d86bb7b..a8461be 100644 --- a/plugins/modules/orion_update_node.py +++ b/plugins/modules/orion_update_node.py @@ -119,7 +119,7 @@ def main(): - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() argument_spec.update( properties=dict(required=False, default={}, type='dict') ) diff --git a/plugins/modules/orion_volume.py b/plugins/modules/orion_volume.py index d8459d7..453fe7f 100644 --- a/plugins/modules/orion_volume.py +++ b/plugins/modules/orion_volume.py @@ -151,7 +151,7 @@ def main(): - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() argument_spec.update( state=dict(required=True, choices=['present', 'absent']), volume=dict( diff --git a/plugins/modules/orion_volume_info.py b/plugins/modules/orion_volume_info.py index a344b39..b34f002 100644 --- a/plugins/modules/orion_volume_info.py +++ b/plugins/modules/orion_volume_info.py @@ -108,7 +108,7 @@ def main(): - argument_spec = orion_argument_spec + argument_spec = orion_argument_spec() argument_spec.update( volume=dict( required=True, type='dict', From 73fac3214f01f2942f1c6a869111374a75d8349d Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Wed, 4 Sep 2024 22:09:32 -0500 Subject: [PATCH 33/40] Clean up role readme --- CHANGELOG.rst | 5 +++ changelogs/changelog.yaml | 4 +++ roles/orion_node/README.md | 65 +++++++++++++++++++++++--------------- 3 files changed, 49 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index eeb479b..af0ab25 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -25,6 +25,11 @@ Minor Changes - Updated orion_update_node exmaples to show updating to SNMPv3. - orion_node role - added tasks for new modules orion_node_ncm and orion_node_hardware_health +Bugfixes +-------- + +- Fixed an issue where ansible-lint would complain about missing parameters when a single yaml doc used multiple modules. + v2.0.0 ====== diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index 939b760..7bb183a 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -187,6 +187,9 @@ releases: release_date: '2024-04-18' 2.1.0: changes: + bugfixes: + - Fixed an issue where ansible-lint would complain about missing parameters + when a single yaml doc used multiple modules. major_changes: - Added orion_node_hardware_health module. This module allows for adding and removing hardware health sensors in Solarwinds Orion. @@ -198,5 +201,6 @@ releases: addition to creating orion_node_hardware_health module. fragments: - 2.1.0.yml + - bugfix.yml - role_updates.yml release_date: '2024-07-12' diff --git a/roles/orion_node/README.md b/roles/orion_node/README.md index e20364c..5450b98 100644 --- a/roles/orion_node/README.md +++ b/roles/orion_node/README.md @@ -1,4 +1,4 @@ -Role Name +orion_node ========= Adds an node to be monitored in Solarwinds. @@ -12,32 +12,47 @@ Role Variables -------------- defaults/main.yml -orion_node_solarwinds_server - Defaults to variable {{ solarwinds_server }} -orion_node_solarwinds_username - Defaults to variable {{ solarwinds_username }} -orion_node_solarwinds_password - Defaults to variable {{ solarwinds_password }} -orion_node_caption_name - Default {{ ansible_facts.nodename }}, override if you aren't gathering facts or for custom caption -orion_node_ip_address - Default {{ ansible_facts.default_ipv4.address }}, override if you aren't gathering facts -orion_node_polling_method - Default ICMP -orion_node_snmp_pollers - list, elements are dicts (name, enabled(bool)). Default is only CPU and Memory pollers. -orion_node_discover_interfaces - Default false, whether to discover and add all interfaces when polling method is SNMP -orion_node_ncm - Default false, whether to add node to NCM +```yaml +orion_node_solarwinds_server: "{{ solarwinds_server }}" +orion_node_solarwinds_username: "{{ solarwinds_username }}" +orion_node_solarwinds_password: "{{ solarwinds_password }}" +orion_node_caption_name: "{{ ansible_facts.nodename }}" +orion_node_ip_address: "{{ ansible_facts.default_ipv4.address }}" +orion_node_polling_method: ICMP +orion_node_snmp_pollers: + - name: N.Cpu.SNMP.HrProcessorLoad + enabled: true + - name: N.Memory.SNMP.NetSnmpReal + enabled: true +orion_node_discover_interfaces: false +orion_node_ncm: false +orion_node_snmp_port: 161 +orion_node_snmp_allow_64: true +``` -Optional variables -orion_node_snmp_version - required when orion_node_polling method is SNMP, set which version of SNMP (choices: 2, 3) -orion_node_ro_community_string - required when SNMP version is 2 -orion_node_snmpv3_credential_set - required when SNMP version is 3 -orion_node_snmpv3_username - required when SNMP version is 3 -orion_node_snmpv3_auth_key - required when SNMP version is 3 -orion_node_snmpv3_priv_key - required when SNMP version is 3 -orion_node_snmp_port - override default SNMP port -orion_node_snmp_allow_64 - override default "True" value of device supporting 64 bit counters +Variables required depending on the values of defaults +```yaml +# required when orion_node_polling method is SNMP, set which version of SNMP (choices: 2, 3) +orion_node_snmp_version: +# required when SNMP version is 2 +orion_node_ro_community_string: +# required when SNMP version is 3 +orion_node_snmpv3_credential_set: +orion_node_snmpv3_username: +orion_node_snmpv3_auth_key: +orion_node_snmpv3_priv_key: -orion_node_custom_pollers - list, additional custom UnDP pollers -orion_node_interfaces - list, interfaces to monitor -orion_node_volumes - list, volumes to monitor -orion_node_applications - list, APM templates to add to node -orion_node_custom_properties - list, elements are dicts (name, value), custom property names and values to set -orion_node_hardware_health_poller - string, Name of the Hardware Health poller to enable on node +``` + +Optional variables, define these if you need to configure +```yaml +orion_node_custom_pollers: list, additional custom UnDP pollers +orion_node_interfaces: list, interfaces to monitor +orion_node_volumes: list, volumes to monitor +orion_node_applications: list, APM templates to add to node +orion_node_custom_properties: list, elements are dicts (name, value), custom property names and values to set +orion_node_hardware_health_poller: string, Name of the Hardware Health poller to enable on node +``` Example Playbook From d3065575bb7dcc6001fe7a3efd57034ff1747dbd Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Mon, 9 Sep 2024 16:16:22 -0500 Subject: [PATCH 34/40] Update role and readme for NCM profile_name param --- roles/orion_node/README.md | 1 + roles/orion_node/tasks/main.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/roles/orion_node/README.md b/roles/orion_node/README.md index 5450b98..cc0597e 100644 --- a/roles/orion_node/README.md +++ b/roles/orion_node/README.md @@ -52,6 +52,7 @@ orion_node_volumes: list, volumes to monitor orion_node_applications: list, APM templates to add to node orion_node_custom_properties: list, elements are dicts (name, value), custom property names and values to set orion_node_hardware_health_poller: string, Name of the Hardware Health poller to enable on node +orion_node_ncm_profile_name: string, Name of NCM profile if managing node in NCM ``` diff --git a/roles/orion_node/tasks/main.yml b/roles/orion_node/tasks/main.yml index 42a6b21..30b951d 100644 --- a/roles/orion_node/tasks/main.yml +++ b/roles/orion_node/tasks/main.yml @@ -124,4 +124,5 @@ password: "{{ orion_node_solarwinds_password }}" state: present name: "{{ orion_node_caption_name }}" + profile_name: "{{ orion_node_ncm_profile_name }}" ... From 2ceaf5c88eeb45cbcdfc8fdf73a3fa7e0816c07e Mon Sep 17 00:00:00 2001 From: Andrew Bailey Date: Fri, 13 Sep 2024 15:38:02 +0000 Subject: [PATCH 35/40] Changed orion_verify to verify --- plugins/inventory/orion_nodes_inventory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/inventory/orion_nodes_inventory.py b/plugins/inventory/orion_nodes_inventory.py index 3bbdcb3..445b0a9 100644 --- a/plugins/inventory/orion_nodes_inventory.py +++ b/plugins/inventory/orion_nodes_inventory.py @@ -231,7 +231,7 @@ def get_orion_nodes(self): 'username': self.get_option('orion_username'), 'password': orion_password, 'port': self.get_option('orion_port'), - 'verify': self.get_option('orion_verify'), + 'verify': self.get_option('verify'), } __SWIS__ = SwisClient(**swis_options) __SWIS__.query('SELECT uri FROM Orion.Environment') From d9105da5b0bbedba7b6953c288135c808b267e8d Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Wed, 25 Sep 2024 13:28:27 -0500 Subject: [PATCH 36/40] add task to example playbook to validate snmp polls --- playbooks/orion_add_node.yml | 90 ++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/playbooks/orion_add_node.yml b/playbooks/orion_add_node.yml index 5e4a311..24a6ac1 100644 --- a/playbooks/orion_add_node.yml +++ b/playbooks/orion_add_node.yml @@ -3,46 +3,58 @@ hosts: "{{ target }}" gather_facts: true # if you don't want to use ansible facts, make sure to then override default caption and IP variables - vars: # Be sure to check out the role README for a full list of variables - solarwinds_server: 127.0.0.1 - solarwinds_username: admin - solarwinds_password: changeme2345 - orion_node_polling_method: SNMP - orion_node_snmp_version: 2 - orion_node_ro_community_string: community - orion_node_discover_interfaces: true - orion_node_volumes: - - / - - /home - - /var/log - # These are the basic pollers for a Linux node - # If you have other pollers enabled, such as Topology Layer 3, you can find a list by adding a node manually, - # then run the following query against the orion database: - - # SELECT n.Caption, p.PollerType, p.Enabled - # from Orion.Nodes n - # left join Orion.Pollers as p on p.NetObjectID = n.NodeId - # where n.Caption = 'your_node_caption_here' - orion_node_snmp_pollers: - - name: N.LoadAverage.SNMP.Linux - enabled: true - - name: N.Cpu.SNMP.HrProcessorLoad - enabled: true - - name: N.Memory.SNMP.NetSnmpReal - enabled: true + # Be sure to check out the role README for a full list of variables + roles: + - role: solarwinds.orion.orion_node + solarwinds_server: 127.0.0.1 + solarwinds_username: admin + solarwinds_password: changeme2345 + orion_node_polling_method: SNMP + orion_node_snmp_version: 2 + orion_node_ro_community_string: community + orion_node_discover_interfaces: true + orion_node_volumes: + - / + - /home + - /var/log + # These are the basic pollers for a Linux node + # If you have other pollers enabled, such as Topology Layer 3, you can find a list by adding a node manually, + # then run the following query against the orion database: - # The same can be done for custom pollers, if you have any. - # This query can get a list of Custom Pollers assigned to a node: + # SELECT n.Caption, p.PollerType, p.Enabled + # from Orion.Nodes n + # left join Orion.Pollers as p on p.NetObjectID = n.NodeId + # where n.Caption = 'your_node_caption_here' + orion_node_snmp_pollers: + - name: N.LoadAverage.SNMP.Linux + enabled: true + - name: N.Cpu.SNMP.HrProcessorLoad + enabled: true + - name: N.Memory.SNMP.NetSnmpReal + enabled: true + # The same can be done for custom pollers, if you have any. + # This query can get a list of Custom Pollers assigned to a node: - # SELECT n.Caption, c.CustomPollerName - # from Orion.Nodes n - # left join Orion.NPM.CustomPollerAssignment as c on c.NodeID = n.NodeID - # where n.Caption = 'your_node_caption_here' + # SELECT n.Caption, c.CustomPollerName + # from Orion.Nodes n + # left join Orion.NPM.CustomPollerAssignment as c on c.NodeID = n.NodeID + # where n.Caption = 'your_node_caption_here' - # You can also get a full list of custom pollers available with this query: - # SELECT GroupName, UniqueName - # FROM Orion.NPM.CustomPollers - # Order By GroupName + # You can also get a full list of custom pollers available with this query: + # SELECT GroupName, UniqueName + # FROM Orion.NPM.CustomPollers + # Order By GroupName - roles: - - { role: solarwinds.orion.orion_node } + tasks: + - name: Validate SNMP is polling after node add + solarwinds.orion.orion_node_info: + hostname: "{{ solarwinds_server }}" + username: "{{ solarwinds_user }}" + password: "{{ solarwinds_pass }}" + name: "{{ orion_node_caption }}" + delegate_to: localhost + register: orion_node_info + until: orion_node_info.orion_node.lastsystemuptimepollutc + retries: 6 + delay: 5 +... From ec72404f959ffbcfd48f59ca50ffa8bfde1b9a22 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Wed, 25 Sep 2024 13:43:01 -0500 Subject: [PATCH 37/40] add lastsystemuptimepollutc to orion_node in RETURN block --- plugins/modules/orion_custom_property.py | 1 + plugins/modules/orion_node.py | 1 + plugins/modules/orion_node_application.py | 1 + plugins/modules/orion_node_custom_poller.py | 1 + plugins/modules/orion_node_hardware_health.py | 1 + plugins/modules/orion_node_info.py | 1 + plugins/modules/orion_node_interface.py | 1 + plugins/modules/orion_node_interface_info.py | 1 + plugins/modules/orion_node_ncm.py | 1 + plugins/modules/orion_node_poller.py | 1 + plugins/modules/orion_node_poller_info.py | 1 + plugins/modules/orion_update_node.py | 1 + plugins/modules/orion_volume.py | 1 + plugins/modules/orion_volume_info.py | 1 + 14 files changed, 14 insertions(+) diff --git a/plugins/modules/orion_custom_property.py b/plugins/modules/orion_custom_property.py index 4e08a2f..cd490e5 100644 --- a/plugins/modules/orion_custom_property.py +++ b/plugins/modules/orion_custom_property.py @@ -69,6 +69,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", diff --git a/plugins/modules/orion_node.py b/plugins/modules/orion_node.py index 35b2ed3..3b3376a 100644 --- a/plugins/modules/orion_node.py +++ b/plugins/modules/orion_node.py @@ -219,6 +219,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", diff --git a/plugins/modules/orion_node_application.py b/plugins/modules/orion_node_application.py index 328bbcc..252e629 100644 --- a/plugins/modules/orion_node_application.py +++ b/plugins/modules/orion_node_application.py @@ -74,6 +74,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", diff --git a/plugins/modules/orion_node_custom_poller.py b/plugins/modules/orion_node_custom_poller.py index 3b38b8b..6db23d3 100644 --- a/plugins/modules/orion_node_custom_poller.py +++ b/plugins/modules/orion_node_custom_poller.py @@ -62,6 +62,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", diff --git a/plugins/modules/orion_node_hardware_health.py b/plugins/modules/orion_node_hardware_health.py index 865c99a..7538916 100644 --- a/plugins/modules/orion_node_hardware_health.py +++ b/plugins/modules/orion_node_hardware_health.py @@ -83,6 +83,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", diff --git a/plugins/modules/orion_node_info.py b/plugins/modules/orion_node_info.py index 00413f5..06ab42f 100644 --- a/plugins/modules/orion_node_info.py +++ b/plugins/modules/orion_node_info.py @@ -44,6 +44,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", diff --git a/plugins/modules/orion_node_interface.py b/plugins/modules/orion_node_interface.py index a54ff27..038ef9f 100644 --- a/plugins/modules/orion_node_interface.py +++ b/plugins/modules/orion_node_interface.py @@ -88,6 +88,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", diff --git a/plugins/modules/orion_node_interface_info.py b/plugins/modules/orion_node_interface_info.py index e0e1f81..c901bf2 100644 --- a/plugins/modules/orion_node_interface_info.py +++ b/plugins/modules/orion_node_interface_info.py @@ -44,6 +44,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", diff --git a/plugins/modules/orion_node_ncm.py b/plugins/modules/orion_node_ncm.py index 635c3a2..42e83c3 100644 --- a/plugins/modules/orion_node_ncm.py +++ b/plugins/modules/orion_node_ncm.py @@ -61,6 +61,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", diff --git a/plugins/modules/orion_node_poller.py b/plugins/modules/orion_node_poller.py index 7124960..c53cc0a 100644 --- a/plugins/modules/orion_node_poller.py +++ b/plugins/modules/orion_node_poller.py @@ -93,6 +93,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", diff --git a/plugins/modules/orion_node_poller_info.py b/plugins/modules/orion_node_poller_info.py index 09bf89c..37c1446 100644 --- a/plugins/modules/orion_node_poller_info.py +++ b/plugins/modules/orion_node_poller_info.py @@ -50,6 +50,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", diff --git a/plugins/modules/orion_update_node.py b/plugins/modules/orion_update_node.py index a8461be..07bacea 100644 --- a/plugins/modules/orion_update_node.py +++ b/plugins/modules/orion_update_node.py @@ -86,6 +86,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", diff --git a/plugins/modules/orion_volume.py b/plugins/modules/orion_volume.py index 453fe7f..f11b996 100644 --- a/plugins/modules/orion_volume.py +++ b/plugins/modules/orion_volume.py @@ -101,6 +101,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", diff --git a/plugins/modules/orion_volume_info.py b/plugins/modules/orion_volume_info.py index b34f002..fd5e1c2 100644 --- a/plugins/modules/orion_volume_info.py +++ b/plugins/modules/orion_volume_info.py @@ -58,6 +58,7 @@ sample: { "caption": "localhost", "ipaddress": "127.0.0.1", + "lastsystemuptimepollutc": "2024-09-25T18:34:20.7630000Z", "netobjectid": "N:12345", "nodeid": "12345", "objectsubtype": "SNMP", From 01b129952b929922c0b1f6f7a5e68248ac6e32b6 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Wed, 2 Oct 2024 18:10:04 -0500 Subject: [PATCH 38/40] rolling back f1d106648a52b5d2c7b2ce992b7a38d92a9c974e as it is not passing integration test. Discovered context doesn't have the 'Name' property --- plugins/modules/orion_node_interface.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/modules/orion_node_interface.py b/plugins/modules/orion_node_interface.py index 038ef9f..0dd3e52 100644 --- a/plugins/modules/orion_node_interface.py +++ b/plugins/modules/orion_node_interface.py @@ -106,7 +106,7 @@ elements: dict sample: [ { - "Name": "lo", + "Caption": "lo", "InterfaceID": 0, "Manageable": true, "ifAdminStatus": 1, @@ -117,7 +117,7 @@ "ifType": 24 }, { - "Name": "eth0", + "Caption": "eth0", "InterfaceID": 268420, "Manageable": true, "ifAdminStatus": 1, @@ -135,7 +135,7 @@ elements: dict sample: [ { - "Name": "lo", + "Caption": "lo", "InterfaceID": 0, "Manageable": true, "ifAdminStatus": 1, @@ -197,11 +197,11 @@ def main(): try: if not module.params['interface']: for interface in discovered: - if not orion.get_interface(node, interface['Name']): + if not orion.get_interface(node, interface['Caption']): changed = True interfaces.append(interface) if not module.check_mode: - orion.add_interface(node, interface['Name'], False, discovered) + orion.add_interface(node, interface['Caption'], False, discovered) else: get_int = orion.get_interface(node, module.params['interface']) if not get_int: @@ -217,11 +217,11 @@ def main(): try: if not module.params['interface']: for interface in discovered: - if orion.get_interface(node, interface['Name']): + if orion.get_interface(node, interface['Caption']): changed = True interfaces.append(interface) if not module.check_mode: - orion.remove_interface(node, interface['Name']) + orion.remove_interface(node, interface['Caption']) else: get_int = orion.get_interface(node, module.params['interface']) if get_int: From f1e6bcb878ff909dd53217ae4bbf1bfaf00a582d Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Wed, 2 Oct 2024 18:28:11 -0500 Subject: [PATCH 39/40] update changelog --- CHANGELOG.rst | 16 ++++++++-------- changelogs/changelog.yaml | 19 ++++++++++++++++--- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index af0ab25..d746e68 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,23 +4,29 @@ Solarwinds.Orion Release Notes .. contents:: Topics - v2.1.0 ====== Release Summary --------------- -Minor Updates to orion_node and orion_update_node modules in addition to creating orion_node_hardware_health module. +Released 2024-10-02 Major Changes ------------- +- Added module orion_node_interface_info to get interfaces currently monitored for a node. - Added orion_node_hardware_health module. This module allows for adding and removing hardware health sensors in Solarwinds Orion. Minor Changes ------------- +- Add a poll_now() function to the OrionModule +- Add a profile_name parameter to orion_node_ncm +- Add correct check_mode logic to orion_ndoe_ncm +- Call poll_now() for SNMP nodes in orion_node_info module. This logic will allow using 'until' task logic to validate node is polling. +- Modified the example playbook for orion_add_node.yml to use the role keyword, and include a task for SNMP poll verification. +- Update get_node() function to also return LastSystemUptimePollUtc - Updated orion_node module to no longer require snmpv3 credential set. - Updated orion_update_node exmaples to show updating to SNMPv3. - orion_node role - added tasks for new modules orion_node_ncm and orion_node_hardware_health @@ -116,7 +122,6 @@ Release Summary | Released 2023-12-1 - Major Changes ------------- @@ -130,7 +135,6 @@ Release Summary | Released 2023-09-26 - Major Changes ------------- @@ -149,7 +153,6 @@ Release Summary | Released 2023-08-27 - Minor Changes ------------- @@ -168,7 +171,6 @@ Release Summary | Released 2023-08-10 - Minor Changes ------------- @@ -190,7 +192,6 @@ Release Summary | Released 2023-07-14 - Minor Changes ------------- @@ -211,7 +212,6 @@ Release Summary | Released 2023-03-18 - New Modules ----------- diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index 7bb183a..1b194a2 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -191,16 +191,29 @@ releases: - Fixed an issue where ansible-lint would complain about missing parameters when a single yaml doc used multiple modules. major_changes: + - Added module orion_node_interface_info to get interfaces currently monitored + for a node. - Added orion_node_hardware_health module. This module allows for adding and removing hardware health sensors in Solarwinds Orion. minor_changes: + - Add a poll_now() function to the OrionModule + - Add a profile_name parameter to orion_node_ncm + - Add correct check_mode logic to orion_ndoe_ncm + - Call poll_now() for SNMP nodes in orion_node_info module. This logic will + allow using 'until' task logic to validate node is polling. + - Modified the example playbook for orion_add_node.yml to use the role keyword, + and include a task for SNMP poll verification. + - Update get_node() function to also return LastSystemUptimePollUtc - Updated orion_node module to no longer require snmpv3 credential set. - Updated orion_update_node exmaples to show updating to SNMPv3. - orion_node role - added tasks for new modules orion_node_ncm and orion_node_hardware_health - release_summary: Minor Updates to orion_node and orion_update_node modules in - addition to creating orion_node_hardware_health module. + release_summary: Released 2024-10-02 fragments: - 2.1.0.yml - bugfix.yml + - orion_node_interface_info.yml + - orion_node_ncm.yml + - orion_node_playbook.yml - role_updates.yml - release_date: '2024-07-12' + - utils.yml + release_date: '2024-10-02' From bd3309c3df2beb9767f4ca91f912ae9c6e01ac99 Mon Sep 17 00:00:00 2001 From: Josh Eisenbath Date: Wed, 2 Oct 2024 18:28:47 -0500 Subject: [PATCH 40/40] build 2.1.0 tarball --- releases/solarwinds-orion-2.1.0.tar.gz | Bin 0 -> 30550 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 releases/solarwinds-orion-2.1.0.tar.gz diff --git a/releases/solarwinds-orion-2.1.0.tar.gz b/releases/solarwinds-orion-2.1.0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..aa6570c6f30f14e5618b90612842813c736a218c GIT binary patch literal 30550 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