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