From 7c888b86ba82c4e49ee16130d9cb9c068b95c805 Mon Sep 17 00:00:00 2001
From: Kevin Breit <kevin.breit@kevinbreit.net>
Date: Wed, 20 Jun 2018 02:44:50 -0500
Subject: [PATCH] New module - meraki_device (#41631)

* Initial commit for meraki_device module
- Allow claiming, removal, updating, and querying of devices
- Integration tests are included
- Integration tests are not complete because physical gear is required
- Integration tests also require Meraki subscriptions

* Added support for serial number query without network

* Added support for net_id and net_name

* Changes recommended by ansible-test for PEP8 and documentation

* Remove duplicate state in example

* Fix typo
---
 .../modules/network/meraki/meraki_device.py   | 412 ++++++++++++++++++
 .../integration/targets/meraki_device/aliases |   1 +
 .../targets/meraki_device/tasks/main.yml      | 202 +++++++++
 3 files changed, 615 insertions(+)
 create mode 100644 lib/ansible/modules/network/meraki/meraki_device.py
 create mode 100644 test/integration/targets/meraki_device/aliases
 create mode 100644 test/integration/targets/meraki_device/tasks/main.yml

diff --git a/lib/ansible/modules/network/meraki/meraki_device.py b/lib/ansible/modules/network/meraki/meraki_device.py
new file mode 100644
index 00000000000..7ac4c9969ab
--- /dev/null
+++ b/lib/ansible/modules/network/meraki/meraki_device.py
@@ -0,0 +1,412 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# Copyright: (c) 2018, Kevin Breit (@kbreit) <kevin.breit@kevinbreit.net>
+# 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
+
+ANSIBLE_METADATA = {
+    'metadata_version': '1.1',
+    'status': ['preview'],
+    'supported_by': 'community'
+}
+
+DOCUMENTATION = r'''
+---
+module: meraki_device
+short_description: Manage devices in the Meraki cloud
+version_added: "2.7"
+description:
+- Visibility into devices associated to a Meraki environment.
+notes:
+- This module does not support claiming of devices or licenses into a Meraki organization.
+- More information about the Meraki API can be found at U(https://dashboard.meraki.com/api_docs).
+- Some of the options are likely only used for developers within Meraki.
+options:
+    state:
+        description:
+        - Query an organization.
+        choices: [absent, present, query]
+        default: query
+    org_name:
+        description:
+        - Name of organization.
+        - If C(clone) is specified, C(org_name) is the name of the new organization.
+        aliases: [ organization ]
+    org_id:
+        description:
+        - ID of organization.
+    net_name:
+        description:
+        - Name of a network.
+        aliases: [network]
+    net_id:
+        description:
+        - ID of a network.
+    serial:
+        description:
+        - Serial number of a device to query.
+    hostname:
+        description:
+        - Hostname of network device to search for.
+        aliases: [name]
+    model:
+        description:
+        - Model of network device to search for.
+    tags:
+        description:
+        - Space delimited list of tags to assign to device.
+    lat:
+        description:
+        - Latitude of device's geographic location.
+        - Use negative number for southern hemisphere.
+        aliases: [latitude]
+    lng:
+        description:
+        - Longitude of device's geographic location.
+        - Use negative number for western hemisphere.
+        aliases: [longitude]
+    address:
+        description:
+        - Postal address of device's location.
+    move_map_marker:
+        description:
+        - Whether or not to set the latitude and longitude of a device based on the new address.
+        - Only applies when C(lat) and C(lng) are not specified.
+        type: bool
+    serial_lldp_cdp:
+        description:
+        - Serial number of device to query LLDP/CDP information from.
+    lldp_cdp_timespan:
+        description:
+        - Timespan, in seconds, used to query LLDP and CDP information.
+        - Must be less than 1 month.
+    serial_uplink:
+        description:
+        - Serial number of device to query uplink information from.
+
+
+author:
+- Kevin Breit (@kbreit)
+extends_documentation_fragment: meraki
+'''
+
+EXAMPLES = r'''
+- name: Query all devices in an organization.
+  meraki_device:
+    auth_key: abc12345
+    org_name: YourOrg
+    state: query
+  delegate_to: localhost
+
+- name: Query all devices in a network.
+  meraki_device:
+    auth_key: abc12345
+    org_name: YourOrg
+    net_name: YourNet
+    state: query
+  delegate_to: localhost
+
+- name: Query a device by serial number.
+  meraki_device:
+    auth_key: abc12345
+    org_name: YourOrg
+    net_name: YourNet
+    serial: ABC-123
+    state: query
+  delegate_to: localhost
+
+- name: Lookup uplink information about a device.
+  meraki_device:
+    auth_key: abc12345
+    org_name: YourOrg
+    net_name: YourNet
+    serial_uplink: ABC-123
+    state: query
+  delegate_to: localhost
+
+- name: Lookup LLDP and CDP information about devices connected to specified device.
+  meraki_device:
+    auth_key: abc12345
+    org_name: YourOrg
+    net_name: YourNet
+    serial_lldp_cdp: ABC-123
+    state: query
+  delegate_to: localhost
+
+- name: Lookup a device by hostname.
+  meraki_device:
+    auth_key: abc12345
+    org_name: YourOrg
+    net_name: YourNet
+    hostname: main-switch
+    state: query
+  delegate_to: localhost
+
+- name: Query all devices of a specific model.
+  meraki_device:
+    auth_key: abc123
+    org_name: YourOrg
+    net_name: YourNet
+    model: MR26
+    state: query
+  delegate_to: localhost
+
+- name: Update information about a device.
+  meraki_device:
+    auth_key: abc123
+    org_name: YourOrg
+    net_name: YourNet
+    state: present
+    serial: '{{serial}}'
+    name: mr26
+    address: 1060 W. Addison St., Chicago, IL
+    lat: 41.948038
+    lng: -87.65568
+    tags: recently-added
+  delegate_to: localhost
+
+- name: Claim a deivce into a network.
+  meraki_device:
+    auth_key: abc123
+    org_name: YourOrg
+    net_name: YourNet
+    serial: ABC-123
+    state: present
+  delegate_to: localhost
+
+- name: Remove a device from a network.
+  meraki_device:
+    auth_key: abc123
+    org_name: YourOrg
+    net_name: YourNet
+    serial: ABC-123
+    state: absent
+  delegate_to: localhost
+'''
+
+RETURN = r'''
+response:
+    description: Data returned from Meraki dashboard.
+    type: dict
+    returned: info
+'''
+
+import os
+from ansible.module_utils.basic import AnsibleModule, json, env_fallback
+from ansible.module_utils._text import to_native
+from ansible.module_utils.network.meraki.meraki import MerakiModule, meraki_argument_spec
+
+
+def format_tags(tags):
+    return " {tags} ".format(tags=tags)
+
+
+def is_device_valid(meraki, serial, data):
+    for device in data:
+        if device['serial'] == serial:
+            return True
+    return False
+
+
+def temp_get_nets(meraki, org_name, net_name):
+    org_id = meraki.get_org_id(org_name)
+    path = meraki.construct_path('get_all', function='network', org_id=org_id)
+    r = meraki.request(path, method='GET')
+    return r
+
+
+def main():
+
+    # define the available arguments/parameters that a user can pass to
+    # the module
+    argument_spec = meraki_argument_spec()
+    argument_spec.update(state=dict(type='str', choices=['absent', 'present', 'query'], default='query'),
+                         net_name=dict(type='str', aliases=['network']),
+                         net_id=dict(type='str'),
+                         serial=dict(type='str'),
+                         serial_uplink=dict(type='str'),
+                         serial_lldp_cdp=dict(type='str'),
+                         lldp_cdp_timespan=dict(type='int'),
+                         hostname=dict(type='str', aliases=['name']),
+                         model=dict(type='str'),
+                         tags=dict(type='str'),
+                         lat=dict(type='float', aliases=['latitude']),
+                         lng=dict(type='float', aliases=['longitude']),
+                         address=dict(type='str'),
+                         move_map_marker=dict(type='bool'),
+                         )
+
+    # seed the result dict in the object
+    # we primarily care about changed and state
+    # change is if this module effectively modified the target
+    # state will include any data that you want your module to pass back
+    # for consumption, for example, in a subsequent task
+    result = dict(
+        changed=False,
+    )
+    # the AnsibleModule object will be our abstraction working with Ansible
+    # this includes instantiation, a couple of common attr would be the
+    # args/params passed to the execution, as well as if the module
+    # supports check mode
+    module = AnsibleModule(argument_spec=argument_spec,
+                           supports_check_mode=True,
+                           )
+    meraki = MerakiModule(module, function='device')
+
+    if meraki.params['serial_lldp_cdp'] and not meraki.params['lldp_cdp_timespan']:
+        meraki.fail_json(msg='lldp_cdp_timespan is required when querying LLDP and CDP information')
+    if meraki.params['net_name'] and meraki.params['net_id']:
+        meraki.fail_json(msg='net_name and net_id are mutually exclusive')
+
+    meraki.params['follow_redirects'] = 'all'
+
+    query_urls = {'device': '/networks/{net_id}/devices',
+                  }
+
+    query_device_urls = {'device': '/networks/{net_id}/devices/',
+                         }
+
+    claim_device_urls = {'device': '/networks/{net_id}/devices/claim',
+                         }
+
+    update_device_urls = {'device': '/networks/{net_id}/devices/',
+                          }
+
+    delete_device_urls = {'device': '/networks/{net_id}/devices/',
+                          }
+
+    meraki.url_catalog['get_all'].update(query_urls)
+    meraki.url_catalog['get_device'] = query_device_urls
+    meraki.url_catalog['create'] = claim_device_urls
+    meraki.url_catalog['update'] = update_device_urls
+    meraki.url_catalog['delete'] = delete_device_urls
+
+    payload = None
+
+    # if the user is working with this module in only check mode we do not
+    # want to make any changes to the environment, just return the current
+    # state with no modifications
+    # FIXME: Work with Meraki so they can implement a check mode
+    if module.check_mode:
+        meraki.exit_json(**meraki.result)
+
+    # execute checks for argument completeness
+
+    # manipulate or modify the state as needed (this is going to be the
+    # part where your module will do what it needs to do)
+    nets = temp_get_nets(meraki, meraki.params['org_name'], meraki.params['net_name'])
+
+    if meraki.params['state'] == 'query':
+        if meraki.params['net_name'] or meraki.params['net_id']:
+            device = []
+            if meraki.params['net_name']:
+                net_id = meraki.get_net_id(net_name=meraki.params['net_name'], data=nets)
+            elif meraki.params['net_id']:
+                net_id = meraki.params['net_id']
+            if meraki.params['serial']:
+                path = meraki.construct_path('get_device', net_id=net_id) + meraki.params['serial']
+                request = meraki.request(path, method='GET')
+                device.append(request)
+                meraki.result['data'] = device
+            elif meraki.params['serial_uplink']:
+                path = meraki.construct_path('get_device', net_id=net_id) + meraki.params['serial_uplink'] + '/uplink'
+                meraki.result['data'] = (meraki.request(path, method='GET'))
+            elif meraki.params['serial_lldp_cdp']:
+                if meraki.params['lldp_cdp_timespan'] > 2592000:
+                    meraki.fail_json(msg='LLDP/CDP timespan must be less than a month (2592000 seconds)')
+                path = meraki.construct_path('get_device', net_id=net_id) + meraki.params['serial_lldp_cdp'] + '/lldp_cdp'
+                path = path + '?timespan=' + str(meraki.params['lldp_cdp_timespan'])
+                device.append(meraki.request(path, method='GET'))
+                meraki.result['data'] = device
+            elif meraki.params['hostname']:
+                path = meraki.construct_path('get_all', net_id=net_id)
+                devices = meraki.request(path, method='GET')
+                for unit in devices:
+                    if unit['name'] == meraki.params['hostname']:
+                        device.append(unit)
+                        meraki.result['data'] = device
+            elif meraki.params['model']:
+                path = meraki.construct_path('get_all', net_id=net_id)
+                devices = meraki.request(path, method='GET')
+                device_match = []
+                for device in devices:
+                    if device['model'] == meraki.params['model']:
+                        device_match.append(device)
+                meraki.result['data'] = device_match
+            else:
+                path = meraki.construct_path('get_all', net_id=net_id)
+                request = meraki.request(path, method='GET')
+                meraki.result['data'] = request
+        else:
+            devices = []
+            for net in nets:  # Gather all devices in all networks
+                path = meraki.construct_path('get_all', net_id=net['id'])
+                request = meraki.request(path, method='GET')
+                devices.append(request)
+            if meraki.params['serial']:
+                for network in devices:
+                    for dev in network:
+                        if dev['serial'] == meraki.params['serial']:
+                            meraki.result['data'] = [dev]
+            else:
+                meraki.result['data'] = devices
+    elif meraki.params['state'] == 'present':
+        device = []
+        if meraki.params['net_name']:
+            net_id = meraki.get_net_id(net_name=meraki.params['net_name'], data=nets)
+        elif meraki.params['net_id']:
+            net_id = meraki.params['net_id']
+        if meraki.params['hostname']:
+            query_path = meraki.construct_path('get_all', net_id=net_id)
+            device_list = meraki.request(query_path, method='GET')
+            if is_device_valid(meraki, meraki.params['serial'], device_list):
+                payload = {'name': meraki.params['hostname'],
+                           'tags': format_tags(meraki.params['tags']),
+                           'lat': meraki.params['lat'],
+                           'lng': meraki.params['lng'],
+                           'address': meraki.params['address'],
+                           'moveMapMarker': meraki.params['move_map_marker'],
+                           }
+                query_path = meraki.construct_path('get_device', net_id=net_id) + meraki.params['serial']
+                device_data = meraki.request(query_path, method='GET')
+                ignore_keys = ['lanIp', 'serial', 'mac', 'model', 'networkId', 'moveMapMarker', 'wan1Ip', 'wan2Ip']
+                if meraki.is_update_required(device_data, payload, optional_ignore=ignore_keys):
+                    path = meraki.construct_path('update', net_id=net_id) + meraki.params['serial']
+                    updated_device = []
+                    updated_device.append(meraki.request(path, method='PUT', payload=json.dumps(payload)))
+                    meraki.result['data'] = updated_device
+                    meraki.result['changed'] = True
+        else:
+            query_path = meraki.construct_path('get_all', net_id=net_id)
+            device_list = meraki.request(query_path, method='GET')
+            if is_device_valid(meraki, meraki.params['serial'], device_list) is False:
+                payload = {'serial': meraki.params['serial']}
+                path = meraki.construct_path('create', net_id=net_id)
+                created_device = []
+                created_device.append(meraki.request(path, method='POST', payload=json.dumps(payload)))
+                meraki.result['data'] = created_device
+                meraki.result['changed'] = True
+    elif meraki.params['state'] == 'absent':
+        device = []
+        if meraki.params['net_name']:
+            net_id = meraki.get_net_id(net_name=meraki.params['net_name'], data=nets)
+        elif meraki.params['net_id']:
+            net_id = meraki.params['net_id']
+        query_path = meraki.construct_path('get_all', net_id=net_id)
+        device_list = meraki.request(query_path, method='GET')
+        if is_device_valid(meraki, meraki.params['serial'], device_list) is True:
+            path = meraki.construct_path('delete', net_id=net_id)
+            path = path + meraki.params['serial'] + '/remove'
+            request = meraki.request(path, method='POST')
+            meraki.result['changed'] = True
+
+    # in the event of a successful module execution, you will want to
+    # simple AnsibleModule.exit_json(), passing the key/value results
+    meraki.exit_json(**meraki.result)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/test/integration/targets/meraki_device/aliases b/test/integration/targets/meraki_device/aliases
new file mode 100644
index 00000000000..89aea537d13
--- /dev/null
+++ b/test/integration/targets/meraki_device/aliases
@@ -0,0 +1 @@
+unsupported
\ No newline at end of file
diff --git a/test/integration/targets/meraki_device/tasks/main.yml b/test/integration/targets/meraki_device/tasks/main.yml
new file mode 100644
index 00000000000..2368162509f
--- /dev/null
+++ b/test/integration/targets/meraki_device/tasks/main.yml
@@ -0,0 +1,202 @@
+---
+- name: Claim a device
+  meraki_device:
+    auth_key: '{{auth_key}}'
+    org_name: '{{test_org_name}}'
+    net_name: '{{test_net_name}}'
+    serial: '{{serial}}'
+    state: present
+  delegate_to: localhost
+  register: claim_device
+
+- debug:
+    msg: '{{claim_device}}'
+
+- assert:
+    that:
+      - claim_device.changed == true
+
+- name: Query all devices
+  meraki_device:
+    auth_key: '{{auth_key}}'
+    org_name: '{{test_org_name}}'
+    state: query
+  delegate_to: localhost
+  register: query_all
+
+- debug:
+    msg: '{{query_all}}'
+
+- assert:
+    that:
+      - query_all.changed == False
+
+- name: Query all devices in one network by network ID
+  meraki_device:
+    auth_key: '{{auth_key}}'
+    org_name: '{{test_org_name}}'
+    net_id: '{{test_net_id}}'
+    state: query
+  delegate_to: localhost
+  register: query_one_net_id
+
+- debug:
+    msg: '{{query_one_net_id}}'
+
+- name: Query all devices in one network
+  meraki_device:
+    auth_key: '{{auth_key}}'
+    org_name: '{{test_org_name}}'
+    net_name: '{{test_net_name}}'
+    state: query
+  delegate_to: localhost
+  register: query_one_net
+
+- debug:
+    msg: '{{query_one_net}}'
+
+- name: Query device by serial
+  meraki_device:
+    auth_key: '{{auth_key}}'
+    org_name: '{{test_org_name}}'
+    serial: '{{serial}}'
+    state: query
+  delegate_to: localhost
+  register: query_serial_no_net
+
+- debug:
+    msg: '{{query_serial_no_net}}'
+
+- name: Query device by serial
+  meraki_device:
+    auth_key: '{{auth_key}}'
+    org_name: '{{test_org_name}}'
+    net_name: '{{test_net_name}}'
+    serial: '{{serial}}'
+    state: query
+  delegate_to: localhost
+  register: query_serial
+
+- debug:
+    msg: '{{query_serial}}'
+
+- assert:
+    that:
+      - query_serial.changed == False
+
+- name: Query uplink information for a device
+  meraki_device:
+    auth_key: '{{auth_key}}'
+    org_name: '{{test_org_name}}'
+    net_name: '{{test_net_name}}'
+    serial_uplink: '{{serial}}'
+    state: query
+  delegate_to: localhost
+  register: query_serial_uplink
+
+- debug:
+    msg: '{{query_serial_uplink}}'
+
+- name: Query LLDP/CDP information about a device
+  meraki_device:
+    auth_key: '{{auth_key}}'
+    org_name: '{{test_org_name}}'
+    net_name: '{{test_net_name}}'
+    serial_lldp_cdp: '{{serial}}'
+    lldp_cdp_timespan: 6000
+    state: query
+  delegate_to: localhost
+  register: query_serial_lldp_cdp
+
+- debug:
+    msg: '{{query_serial_lldp_cdp}}'
+
+- name: Query a device by hostname
+  meraki_device:
+    auth_key: '{{auth_key}}'
+    org_name: '{{test_org_name}}'
+    net_name: '{{test_net_name}}'
+    hostname: test-hostname
+    state: query
+  delegate_to: localhost
+  register: query_hostname
+
+- debug:
+    msg: '{{query_hostname}}'
+
+- name: Query a device by model
+  meraki_device:
+    auth_key: '{{auth_key}}'
+    org_name: '{{test_org_name}}'
+    net_name: '{{test_net_name}}'
+    model: MR26
+    state: query
+  delegate_to: localhost
+  register: query_model
+
+- debug:
+    msg: '{{query_model}}'
+
+- name: Update a device
+  meraki_device:
+    auth_key: '{{auth_key}}'
+    org_name: '{{test_org_name}}'
+    net_name: '{{test_net_name}}'
+    serial: '{{serial}}'
+    name: mr26
+    address: 1060 W. Addison St., Chicago, IL
+    lat: 41.948038
+    lng: -87.65568
+    tags: recently-added
+    state: present
+    move_map_marker: True
+  delegate_to: localhost
+  register: update_device
+
+- debug:
+    msg: '{{update_device}}'
+
+# - assert:
+#     that:
+#       - update_device.changed == true
+#       - '"1060 W. Addison St., Chicago, IL" in update_device.data.0.address'
+
+- name: Update a device with idempotency
+  meraki_device:
+    auth_key: '{{auth_key}}'
+    org_name: '{{test_org_name}}'
+    net_name: '{{test_net_name}}'
+    serial: '{{serial}}'
+    name: mr26
+    address: 1060 W. Addison St., Chicago, IL
+    lat: 41.948038
+    lng: -87.65568
+    tags: recently-added
+    state: present
+    move_map_marker: True
+  delegate_to: localhost
+  register: update_device_idempotent
+
+- debug:
+    msg: '{{update_device_idempotent}}'
+
+- assert:
+    that:
+      - update_device_idempotent.changed == False
+
+- name: Remove a device
+  meraki_device:
+    auth_key: '{{auth_key}}'
+    org_name: '{{test_org_name}}'
+    net_name: '{{test_net_name}}'
+    serial: '{{serial}}'
+    state: absent
+  delegate_to: localhost
+  register: delete_device
+
+- debug:
+    msg: '{{delete_device}}'
+
+- assert:
+    that:
+      - delete_device.changed == true
\ No newline at end of file