diff --git a/docs/docsite/rst/dev_guide/developing_module_utilities.rst b/docs/docsite/rst/dev_guide/developing_module_utilities.rst index 44799514b3..1b40523bdd 100644 --- a/docs/docsite/rst/dev_guide/developing_module_utilities.rst +++ b/docs/docsite/rst/dev_guide/developing_module_utilities.rst @@ -50,6 +50,7 @@ The following is a list of module_utils files and a general description. The mod - network/iosxr/iosxr.py - Definitions and helper functions for modules that manage Cisco IOS-XR networking devices. - network/ironware/ironware.py - Module support utilities for managing Brocade IronWare devices. - network/junos/junos.py - Definitions and helper functions for modules that manage Junos networking devices. +- network/meraki/meraki.py - Utilities specifically for the Meraki network modules. - network/netscaler/netscaler.py - Utilities specifically for the netscaler network modules. - network/nso/nso.py - Utilities for modules that work with Cisco NSO. - network/nxos/nxos.py - Contains definitions and helper functions specific to Cisco NXOS networking devices. diff --git a/lib/ansible/module_utils/network/meraki/__init__.py b/lib/ansible/module_utils/network/meraki/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/ansible/module_utils/network/meraki/meraki.py b/lib/ansible/module_utils/network/meraki/meraki.py new file mode 100644 index 0000000000..4825a9c933 --- /dev/null +++ b/lib/ansible/module_utils/network/meraki/meraki.py @@ -0,0 +1,263 @@ +# -*- coding: utf-8 -*- + +# This code is part of Ansible, but is an independent component + +# This particular file snippet, and this file snippet only, is BSD licensed. +# Modules you write using this snippet, which is embedded dynamically by Ansible +# still belong to the author of the module, and may assign their own license +# to the complete work. + +# Copyright: (c) 2018, Kevin Breit +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import os +from ansible.module_utils.basic import AnsibleModule, json, env_fallback +from ansible.module_utils.urls import fetch_url +from ansible.module_utils._text import to_native, to_bytes, to_text + + +def meraki_argument_spec(): + return dict(auth_key=dict(type='str', no_log=True, fallback=(env_fallback, ['MERAKI_KEY'])), + host=dict(type='str', default='api.meraki.com'), + use_proxy=dict(type='bool', default=False), + use_https=dict(type='bool', default=True), + validate_certs=dict(type='bool', default=True), + output_level=dict(type='str', default='normal', choices=['normal', 'debug']), + timeout=dict(type='int', default=30), + org_name=dict(type='str', aliases=['organization']), + org_id=dict(type='str'), + ) + + +class MerakiModule(object): + + def __init__(self, module, function=None): + self.module = module + self.params = module.params + self.result = dict(changed=False) + self.headers = dict() + self.function = function + + # normal output + self.existing = None + + # info output + self.config = dict() + self.original = None + self.proposed = dict() + self.merged = None + + # debug output + self.filter_string = '' + self.method = None + self.path = None + self.response = None + self.status = None + self.url = None + + # If URLs need to be modified or added for specific purposes, use .update() on the url_catalog dictionary + self.get_urls = {'organizations': '/organizations', + 'networks': '/organizations/{org_id}/networks', + 'admins': '/organizations/{org_id}/admins', + 'configTemplates': '/organizations/{org_id}/configTemplates', + 'samlRoles': '/organizations/{org_id}/samlRoles', + 'ssids': '/networks/{net_id}/ssids', + 'groupPolicies': '/networks/{net_id}/groupPolicies', + 'staticRoutes': '/networks/{net_id}/staticRoutes', + 'vlans': '/networks/{net_id}/vlans', + 'devices': '/networks/{net_id}/devices', + } + + self.get_one_urls = {'organizations': '/organizations/{org_id}', + 'networks': '/networks/{net_id}', + } + + # Module should add URLs which are required by the module + self.url_catalog = {'get_all': self.get_urls, + 'get_one': self.get_one_urls, + 'create': None, + 'update': None, + 'delete': None, + 'misc': None, + } + + if self.module._debug or self.params['output_level'] == 'debug': + self.module.warn('Enable debug output because ANSIBLE_DEBUG was set or output_level is set to debug.') + + # TODO: This needs to be tested + self.module.required_if = [('state', 'present', ['org_name']), + ('state', 'absent', ['org_name']), + ] + # self.module.mutually_exclusive = [('org_id', 'org_name'), + # ] + self.modifiable_methods = ['POST', 'PUT', 'DELETE'] + + self.headers = {'Content-Type': 'application/json', + 'X-Cisco-Meraki-API-Key': module.params['auth_key'], + } + + def define_protocol(self): + ''' Set protocol based on use_https parameters ''' + if self.params['use_https'] is True: + self.params['protocol'] = 'https' + else: + self.params['protocol'] = 'http' + + def is_update_required(self, original, proposed): + ''' Compare original and proposed data to see if an update is needed ''' + is_changed = False + ignored_keys = ('id', 'organizationId') + + # self.fail_json(msg="Update required check", original=original, proposed=proposed) + + for k, v in original.items(): + try: + if k not in ignored_keys: + if v != proposed[k]: + is_changed = True + except KeyError: + if v != '': + is_changed = True + for k, v in proposed.items(): + try: + if k not in ignored_keys: + if v != original[k]: + is_changed = True + except KeyError: + if v != '': + is_changed = True + return is_changed + + def get_orgs(self): + ''' Downloads all organizations ''' + return json.loads(self.request('/organizations', method='GET')) + + def is_org_valid(self, data, org_name=None, org_id=None): + ''' Checks whether a specific org exists and is duplicated ''' + ''' If 0, doesn't exist. 1, exists and not duplicated. >1 duplicated ''' + org_count = 0 + if org_name is not None: + for o in data: + if o['name'] == org_name: + org_count += 1 + if org_id is not None: + for o in data: + if o['id'] == org_id: + org_count += 1 + return org_count + + def get_org_id(self, org_name): + ''' Returns an organization id based on organization name, only if unique + If org_id is specified as parameter, return that instead of a lookup + ''' + orgs = self.get_orgs() + if self.params['org_id'] is not None: + if self.is_org_valid(orgs, org_id=self.params['org_id']) is True: + return self.params['org_id'] + org_count = self.is_org_valid(orgs, org_name=org_name) + if org_count == 0: + self.fail_json(msg='There are no organizations with the name {org_name}'.format(org_name=org_name)) + if org_count > 1: + self.fail_json(msg='There are multiple organizations with the name {org_name}'.format(org_name=org_name)) + elif org_count == 1: + for i in orgs: + if org_name == i['name']: + # self.fail_json(msg=i['id']) + return str(i['id']) + + def get_net(self, org_name, net_name, data=None): + ''' Return network information ''' + if not data: + org_id = self.get_org_id(org_name) + path = '/organizations/{org_id}/networks/{net_id}'.format(org_id=org_id, net_id=self.get_net_id(org_name=org_name, net_name=net_name, data=data)) + return json.loads(self.request('GET', path)) + else: + for n in data: + if n['name'] == net_name: + return n + + def get_net_id(self, org_name=None, net_name=None, data=None): + ''' Return network id from lookup or existing data ''' + if not data: + self.fail_json(msg='Must implement lookup') + for n in data: + if n['name'] == net_name: + return n['id'] + self.fail_json(msg='No network found with the name {0}'.format(net_name)) + + def construct_path(self, action, function=None, org_id=None, net_id=None, org_name=None): + built_path = None + if function is None: + built_path = self.url_catalog[action][self.function] + else: + self.function = function + built_path = self.url_catalog[action][function] + if org_name: + org_id = self.get_org_id(org_name) + + built_path = built_path.format(org_id=org_id, net_id=net_id) + return built_path + + def request(self, path, method=None, payload=None): + ''' Generic HTTP method for Meraki requests ''' + self.path = path + self.define_protocol() + + if method is not None: + self.method = method + self.url = '{protocol}://{host}/api/v0/{path}'.format(path=self.path.lstrip('/'), **self.params) + resp, info = fetch_url(self.module, self.url, + headers=self.headers, + data=payload, + method=self.method, + timeout=self.params['timeout'], + use_proxy=self.params['use_proxy'], + ) + self.response = info['msg'] + self.status = info['status'] + + if self.status >= 300: + self.fail_json(msg='Request failed for {url}: {status} - {msg}'.format(**info)) + return to_native(resp.read()) + + def exit_json(self, **kwargs): + self.result['response'] = self.response + self.result['status'] = self.status + # Return the gory details when we need it + if self.params['output_level'] == 'debug': + self.result['method'] = self.method + self.result['url'] = self.url + + self.result.update(**kwargs) + self.module.exit_json(**self.result) + + def fail_json(self, msg, **kwargs): + self.result['response'] = self.response + self.result['status'] = self.status + + if self.params['output_level'] == 'debug': + if self.url is not None: + self.result['method'] = self.method + self.result['url'] = self.url + + self.result.update(**kwargs) + self.module.fail_json(msg=msg, **self.result) diff --git a/lib/ansible/modules/network/meraki/__init__.py b/lib/ansible/modules/network/meraki/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/ansible/modules/network/meraki/meraki_organization.py b/lib/ansible/modules/network/meraki/meraki_organization.py new file mode 100644 index 0000000000..82ce074db7 --- /dev/null +++ b/lib/ansible/modules/network/meraki/meraki_organization.py @@ -0,0 +1,206 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Kevin Breit (@kbreit) +# 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_organization +short_description: Manage organizations in the Meraki cloud +version_added: "2.6" +description: +- Allows for creation, management, and visibility into organizations within Meraki. +notes: +- 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: + - Create or modify an organization. + choices: ['present', 'query'] + default: present + clone: + description: + - Organization to clone to a new organization. + org_name: + description: + - Name of organization. + - If C(clone) is specified, C(org_name) is the name of the new organization. + aliases: [ name, organization ] + org_id: + description: + - ID of organization. + aliases: [ id ] +author: +- Kevin Breit (@kbreit) +extends_documentation_fragment: meraki +''' + +EXAMPLES = r''' +- name: Create a new organization named YourOrg + meraki_organization: + auth_key: abc12345 + org_name: YourOrg + state: present + delegate_to: localhost + +- name: Query information about all organizations associated to the user + meraki_organization: + auth_key: abc12345 + state: query + delegate_to: localhost + +- name: Query information about a single organization named YourOrg + meraki_organization: + auth_key: abc12345 + org_name: YourOrg + state: query + delegate_to: localhost + +- name: Rename an organization to RenamedOrg + meraki_organization: + auth_key: abc12345 + org_id: 987654321 + org_name: RenamedOrg + state: present + delegate_to: localhost + +- name: Clone an organization named Org to a new one called ClonedOrg + meraki_organization: + auth_key: abc12345 + clone: Org + org_name: ClonedOrg + state: present + 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.urls import fetch_url +from ansible.module_utils._text import to_native +from ansible.module_utils.network.meraki.meraki import MerakiModule, meraki_argument_spec + + +def main(): + + # define the available arguments/parameters that a user can pass to + # the module + argument_spec = meraki_argument_spec() + argument_spec.update(clone=dict(type='str'), + state=dict(type='str', choices=['present', 'query'], default='present'), + org_name=dict(type='str', aliases=['name', 'organization']), + org_id=dict(type='int', aliases=['id']), + ) + + # 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='organizations') + + meraki.params['follow_redirects'] = 'all' + + create_urls = {'organizations': '/organizations', + } + update_urls = {'organizations': '/organizations/{org_id}', + } + clone_urls = {'organizations': '/organizations/{org_id}/clone', + } + + meraki.url_catalog['create'] = create_urls + meraki.url_catalog['update'] = update_urls + meraki.url_catalog['clone'] = clone_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) + orgs = meraki.get_orgs() + if meraki.params['state'] == 'query': + if meraki.params['org_name']: # Query by organization name + module.warn('All matching organizations will be returned, even if there are duplicate named organizations') + for o in orgs: + if o['name'] == meraki.params['org_name']: + meraki.result['data'] = o + elif meraki.params['org_id']: + for o in orgs: + if o['id'] == meraki.params['org_id']: + meraki.result['data'] = o + else: # Query all organizations, no matter what + orgs = meraki.get_orgs() + meraki.result['data'] = orgs + elif meraki.params['state'] == 'present': + if meraki.params['clone']: # Cloning + payload = {'name': meraki.params['org_name']} + meraki.result['data'] = json.loads( + meraki.request( + meraki.construct_path( + 'clone', + org_name=meraki.params['clone'] + ), + payload=json.dumps(payload), + method='POST')) + elif not meraki.params['org_id'] and meraki.params['org_name']: # Create new organization + payload = {'name': meraki.params['org_name']} + meraki.result['data'] = json.loads( + meraki.request( + meraki.construct_path('create'), + method='POST', + payload=json.dumps(payload))) + elif meraki.params['org_id'] and meraki.params['org_name']: # Update an existing organization + payload = {'name': meraki.params['org_name'], + 'id': meraki.params['org_id'], + } + meraki.result['data'] = json.loads( + meraki.request( + meraki.construct_path( + 'update', + org_id=meraki.params['org_id'] + ), + method='PUT', + payload=json.dumps(payload))) + + # 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/lib/ansible/utils/module_docs_fragments/meraki.py b/lib/ansible/utils/module_docs_fragments/meraki.py new file mode 100644 index 0000000000..e86469f99b --- /dev/null +++ b/lib/ansible/utils/module_docs_fragments/meraki.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Kevin Breit (@kbreit) + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +class ModuleDocFragment(object): + # Standard files for documentation fragment + DOCUMENTATION = ''' +notes: +- 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: + auth_key: + description: + - Authentication key provided by the dashboard. Required if environmental variable MERAKI_KEY is not set. + host: + description: + - Hostname for Meraki dashboard + - Only useful for internal Meraki developers + type: string + default: 'api.meraki.com' + use_proxy: + description: + - If C(no), it will not use a proxy, even if one is defined in an environment variable on the target hosts. + type: bool + use_https: + description: + - If C(no), it will use HTTP. Otherwise it will use HTTPS. + - Only useful for internal Meraki developers + type: bool + default: 'yes' + output_level: + description: + - Set amount of debug output during module execution + choices: ['normal', 'debug'] + default: 'normal' + timeout: + description: + - Time to timeout for HTTP requests. + type: int + default: 30 + validate_certs: + description: + - Whether to validate HTTP certificates. + type: bool + default: 'yes' + org_name: + description: + - Name of organization. + aliases: [ organization ] + org_id: + description: + - ID of organization. +''' diff --git a/test/integration/target-prefixes.network b/test/integration/target-prefixes.network index 5849638123..74078a69fb 100644 --- a/test/integration/target-prefixes.network +++ b/test/integration/target-prefixes.network @@ -16,6 +16,7 @@ ios iosxr ironware junos +meraki net netconf nxos diff --git a/test/integration/targets/meraki_organization/aliases b/test/integration/targets/meraki_organization/aliases new file mode 100644 index 0000000000..ad7ccf7ada --- /dev/null +++ b/test/integration/targets/meraki_organization/aliases @@ -0,0 +1 @@ +unsupported diff --git a/test/integration/targets/meraki_organization/tasks/main.yml b/test/integration/targets/meraki_organization/tasks/main.yml new file mode 100644 index 0000000000..bbc3043aad --- /dev/null +++ b/test/integration/targets/meraki_organization/tasks/main.yml @@ -0,0 +1,60 @@ +# Test code for the Meraki Organization module +# Copyright: (c) 2018, Kevin Breit (@kbreit) + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: Test an API key is provided + fail: + msg: Please define an API key + when: auth_key is not defined + +- name: Create a new organization named IntTestOrg + meraki_organization: + auth_key: '{{ auth_key }}' + org_name: IntTestOrg + state: present + output_level: debug + delegate_to: localhost + register: new_org + +- name: List all organizations + meraki_organization: + auth_key: '{{ auth_key }}' + state: query + delegate_to: localhost + register: query_all + +- name: Query information about a single organization named IntTestOrg + meraki_organization: + auth_key: '{{ auth_key }}' + org_name: IntTestOrg + state: query + delegate_to: localhost + register: query_org + +- name: Query information about IntTestOrg by organization ID + meraki_organization: + auth_key: '{{ auth_key }}' + org_id: '{{ query_org.data.id }}' + state: query + delegate_to: localhost + register: query_org_id + +- name: Clone IntTestOrg + meraki_organization: + auth_key: '{{ auth_key }}' + clone: IntTestOrg + org_name: IntTestOrgCloned + state: present + delegate_to: localhost + register: cloned_org + +- name: Present assertions + assert: + that: + - new_org.data.id is defined + - '{{ query_all | length}} > 0' + - query_org.data.id is defined + - 'query_org.data.name == "IntTestOrg"' + - cloned_org.data.id is defined + - 'query_org_id.data.id == query_org.data.id'