1#!/usr/bin/python 2# -*- coding: utf-8 -*- 3 4# (c) 2016, Renato Orgito <orgito@gmail.com> 5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 6 7from __future__ import absolute_import, division, print_function 8__metaclass__ = type 9 10 11ANSIBLE_METADATA = {'metadata_version': '1.1', 12 'status': ['preview'], 13 'supported_by': 'community'} 14 15 16DOCUMENTATION = ''' 17--- 18module: spectrum_device 19short_description: Creates/deletes devices in CA Spectrum. 20description: 21 - This module allows you to create and delete devices in CA Spectrum U(https://www.ca.com/us/products/ca-spectrum.html). 22 - Tested on CA Spectrum 9.4.2, 10.1.1 and 10.2.1 23version_added: "2.6" 24author: "Renato Orgito (@orgito)" 25options: 26 device: 27 aliases: [ host, name ] 28 required: true 29 description: 30 - IP address of the device. 31 - If a hostname is given, it will be resolved to the IP address. 32 community: 33 description: 34 - SNMP community used for device discovery. 35 - Required when C(state=present). 36 landscape: 37 required: true 38 description: 39 - Landscape handle of the SpectroServer to which add or remove the device. 40 state: 41 required: false 42 description: 43 - On C(present) creates the device when it does not exist. 44 - On C(absent) removes the device when it exists. 45 choices: ['present', 'absent'] 46 default: 'present' 47 url: 48 aliases: [ oneclick_url ] 49 required: true 50 description: 51 - HTTP, HTTPS URL of the Oneclick server in the form (http|https)://host.domain[:port] 52 url_username: 53 aliases: [ oneclick_user ] 54 required: true 55 description: 56 - Oneclick user name. 57 url_password: 58 aliases: [ oneclick_password ] 59 required: true 60 description: 61 - Oneclick user password. 62 use_proxy: 63 required: false 64 description: 65 - if C(no), it will not use a proxy, even if one is defined in an environment 66 variable on the target hosts. 67 default: 'yes' 68 type: bool 69 validate_certs: 70 required: false 71 description: 72 - If C(no), SSL certificates will not be validated. This should only be used 73 on personally controlled sites using self-signed certificates. 74 default: 'yes' 75 type: bool 76 agentport: 77 required: false 78 description: 79 - UDP port used for SNMP discovery. 80 default: 161 81notes: 82 - The devices will be created inside the I(Universe) container of the specified landscape. 83 - All the operations will be performed only on the specified landscape. 84''' 85 86EXAMPLES = ''' 87- name: Add device to CA Spectrum 88 local_action: 89 module: spectrum_device 90 device: '{{ ansible_host }}' 91 community: secret 92 landscape: '0x100000' 93 oneclick_url: http://oneclick.example.com:8080 94 oneclick_user: username 95 oneclick_password: password 96 state: present 97 98 99- name: Remove device from CA Spectrum 100 local_action: 101 module: spectrum_device 102 device: '{{ ansible_host }}' 103 landscape: '{{ landscape_handle }}' 104 oneclick_url: http://oneclick.example.com:8080 105 oneclick_user: username 106 oneclick_password: password 107 use_proxy: no 108 state: absent 109''' 110 111RETURN = ''' 112device: 113 description: device data when state = present 114 returned: success 115 type: dict 116 sample: {'model_handle': '0x1007ab', 'landscape': '0x100000', 'address': '10.10.5.1'} 117''' 118 119from socket import gethostbyname, gaierror 120import xml.etree.ElementTree as ET 121 122from ansible.module_utils.basic import AnsibleModule 123from ansible.module_utils.urls import fetch_url 124 125 126def request(resource, xml=None, method=None): 127 headers = { 128 "Content-Type": "application/xml", 129 "Accept": "application/xml" 130 } 131 132 url = module.params['oneclick_url'] + '/spectrum/restful/' + resource 133 134 response, info = fetch_url(module, url, data=xml, method=method, headers=headers, timeout=45) 135 136 if info['status'] == 401: 137 module.fail_json(msg="failed to authenticate to Oneclick server") 138 139 if info['status'] not in (200, 201, 204): 140 module.fail_json(msg=info['msg']) 141 142 return response.read() 143 144 145def post(resource, xml=None): 146 return request(resource, xml=xml, method='POST') 147 148 149def delete(resource): 150 return request(resource, xml=None, method='DELETE') 151 152 153def get_ip(): 154 try: 155 device_ip = gethostbyname(module.params.get('device')) 156 except gaierror: 157 module.fail_json(msg="failed to resolve device ip address for '%s'" % module.params.get('device')) 158 159 return device_ip 160 161 162def get_device(device_ip): 163 """Query OneClick for the device using the IP Address""" 164 resource = '/models' 165 landscape_min = "0x%x" % int(module.params.get('landscape'), 16) 166 landscape_max = "0x%x" % (int(module.params.get('landscape'), 16) + 0x100000) 167 168 xml = """<?xml version="1.0" encoding="UTF-8"?> 169 <rs:model-request throttlesize="5" 170 xmlns:rs="http://www.ca.com/spectrum/restful/schema/request" 171 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 172 xsi:schemaLocation="http://www.ca.com/spectrum/restful/schema/request ../../../xsd/Request.xsd"> 173 <rs:target-models> 174 <rs:models-search> 175 <rs:search-criteria xmlns="http://www.ca.com/spectrum/restful/schema/filter"> 176 <action-models> 177 <filtered-models> 178 <and> 179 <equals> 180 <model-type>SearchManager</model-type> 181 </equals> 182 <greater-than> 183 <attribute id="0x129fa"> 184 <value>{mh_min}</value> 185 </attribute> 186 </greater-than> 187 <less-than> 188 <attribute id="0x129fa"> 189 <value>{mh_max}</value> 190 </attribute> 191 </less-than> 192 </and> 193 </filtered-models> 194 <action>FIND_DEV_MODELS_BY_IP</action> 195 <attribute id="AttributeID.NETWORK_ADDRESS"> 196 <value>{search_ip}</value> 197 </attribute> 198 </action-models> 199 </rs:search-criteria> 200 </rs:models-search> 201 </rs:target-models> 202 <rs:requested-attribute id="0x12d7f" /> <!--Network Address--> 203 </rs:model-request> 204 """.format(search_ip=device_ip, mh_min=landscape_min, mh_max=landscape_max) 205 206 result = post(resource, xml=xml) 207 208 root = ET.fromstring(result) 209 210 if root.get('total-models') == '0': 211 return None 212 213 namespace = dict(ca='http://www.ca.com/spectrum/restful/schema/response') 214 215 # get the first device 216 model = root.find('ca:model-responses', namespace).find('ca:model', namespace) 217 218 if model.get('error'): 219 module.fail_json(msg="error checking device: %s" % model.get('error')) 220 221 # get the attributes 222 model_handle = model.get('mh') 223 224 model_address = model.find('./*[@id="0x12d7f"]').text 225 226 # derive the landscape handler from the model handler of the device 227 model_landscape = "0x%x" % int(int(model_handle, 16) // 0x100000 * 0x100000) 228 229 device = dict( 230 model_handle=model_handle, 231 address=model_address, 232 landscape=model_landscape) 233 234 return device 235 236 237def add_device(): 238 device_ip = get_ip() 239 device = get_device(device_ip) 240 241 if device: 242 module.exit_json(changed=False, device=device) 243 244 if module.check_mode: 245 device = dict( 246 model_handle=None, 247 address=device_ip, 248 landscape="0x%x" % int(module.params.get('landscape'), 16)) 249 module.exit_json(changed=True, device=device) 250 251 resource = 'model?ipaddress=' + device_ip + '&commstring=' + module.params.get('community') 252 resource += '&landscapeid=' + module.params.get('landscape') 253 254 if module.params.get('agentport', None): 255 resource += '&agentport=' + str(module.params.get('agentport', 161)) 256 257 result = post(resource) 258 root = ET.fromstring(result) 259 260 if root.get('error') != 'Success': 261 module.fail_json(msg=root.get('error-message')) 262 263 namespace = dict(ca='http://www.ca.com/spectrum/restful/schema/response') 264 model = root.find('ca:model', namespace) 265 266 model_handle = model.get('mh') 267 model_landscape = "0x%x" % int(int(model_handle, 16) // 0x100000 * 0x100000) 268 269 device = dict( 270 model_handle=model_handle, 271 address=device_ip, 272 landscape=model_landscape, 273 ) 274 275 module.exit_json(changed=True, device=device) 276 277 278def remove_device(): 279 device_ip = get_ip() 280 device = get_device(device_ip) 281 282 if device is None: 283 module.exit_json(changed=False) 284 285 if module.check_mode: 286 module.exit_json(changed=True) 287 288 resource = '/model/' + device['model_handle'] 289 result = delete(resource) 290 291 root = ET.fromstring(result) 292 293 namespace = dict(ca='http://www.ca.com/spectrum/restful/schema/response') 294 error = root.find('ca:error', namespace).text 295 296 if error != 'Success': 297 error_message = root.find('ca:error-message', namespace).text 298 module.fail_json(msg="%s %s" % (error, error_message)) 299 300 module.exit_json(changed=True) 301 302 303def main(): 304 global module 305 module = AnsibleModule( 306 argument_spec=dict( 307 device=dict(required=True, aliases=['host', 'name']), 308 landscape=dict(required=True), 309 state=dict(choices=['present', 'absent'], default='present'), 310 community=dict(required=True, no_log=True), 311 agentport=dict(type='int', default=161), 312 url=dict(required=True, aliases=['oneclick_url']), 313 url_username=dict(required=True, aliases=['oneclick_user']), 314 url_password=dict(required=True, no_log=True, aliases=['oneclick_password']), 315 use_proxy=dict(type='bool', default='yes'), 316 validate_certs=dict(type='bool', default='yes'), 317 ), 318 required_if=[('state', 'present', ['community'])], 319 supports_check_mode=True 320 ) 321 322 if module.params.get('state') == 'present': 323 add_device() 324 else: 325 remove_device() 326 327 328if __name__ == '__main__': 329 main() 330