From 2c60771096d547a1d1862d6a7e167614d2f911de Mon Sep 17 00:00:00 2001 From: Zim Kalinowski Date: Thu, 24 May 2018 07:55:57 +0800 Subject: [PATCH] introducing azure_rm_resource (#39274) * introducing azure_rm_resource * adding missing file * fixed documentation * fixed subresource * don't support tags (yet) * added api_version to the doc * adding api version to the doc * ignoring some doc checks * fixing imports * final sanity fixes * adding idempotency * fixed params * missing query parameter * some updates of azure_rm_resource * try to update test for idempotency * fixed test * try to fix problem temporarily * another approach * fixed spelling * fixed mistake * refactored a bit * fixes * removed unnecessary code * removed assert temporarily * fixed documentation * trying to fix import issues * trying to fix import * try to fix sanity * removed unnecessary pass * removed e324 ignores * resolve conflicts in ignore.txt * fixing ignore txt * revert * try different approach to idempotency * updating tests * fixed task * fixes * getting output value properly * fix test * missing change * fixed bug, changed test * fixed mistake * fixed mistake --- .../module_utils/azure_rm_common_rest.py | 70 +++++ .../modules/cloud/azure/azure_rm_resource.py | 288 ++++++++++++++++++ .../cloud/azure/azure_rm_resource_facts.py | 203 ++++++++++++ .../targets/azure_rm_resource/aliases | 3 + .../targets/azure_rm_resource/meta/main.yml | 2 + .../targets/azure_rm_resource/tasks/main.yml | 77 +++++ 6 files changed, 643 insertions(+) create mode 100644 lib/ansible/module_utils/azure_rm_common_rest.py create mode 100644 lib/ansible/modules/cloud/azure/azure_rm_resource.py create mode 100644 lib/ansible/modules/cloud/azure/azure_rm_resource_facts.py create mode 100644 test/integration/targets/azure_rm_resource/aliases create mode 100644 test/integration/targets/azure_rm_resource/meta/main.yml create mode 100644 test/integration/targets/azure_rm_resource/tasks/main.yml diff --git a/lib/ansible/module_utils/azure_rm_common_rest.py b/lib/ansible/module_utils/azure_rm_common_rest.py new file mode 100644 index 00000000000..b1caa07f76c --- /dev/null +++ b/lib/ansible/module_utils/azure_rm_common_rest.py @@ -0,0 +1,70 @@ +# Copyright (c) 2018 Zim Kalinowski, +# +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +try: + from msrestazure.azure_exceptions import CloudError + from msrestazure.azure_configuration import AzureConfiguration + from msrest.service_client import ServiceClient + import json +except ImportError: + # This is handled in azure_rm_common + AzureConfiguration = object + + +class GenericRestClientConfiguration(AzureConfiguration): + + def __init__(self, credentials, subscription_id, base_url=None): + + if credentials is None: + raise ValueError("Parameter 'credentials' must not be None.") + if subscription_id is None: + raise ValueError("Parameter 'subscription_id' must not be None.") + if not base_url: + base_url = 'https://management.azure.com' + + super(GenericRestClientConfiguration, self).__init__(base_url) + + self.add_user_agent('genericrestclient/1.0') + self.add_user_agent('Azure-SDK-For-Python') + + self.credentials = credentials + self.subscription_id = subscription_id + + +class GenericRestClient(object): + + def __init__(self, credentials, subscription_id, base_url=None): + self.config = GenericRestClientConfiguration(credentials, subscription_id, base_url) + self._client = ServiceClient(self.config.credentials, self.config) + self.models = None + + def query(self, url, method, query_parameters, header_parameters, body, expected_status_codes): + # Construct and send request + operation_config = {} + + request = None + + if method == 'GET': + request = self._client.get(url, query_parameters) + elif method == 'PUT': + request = self._client.put(url, query_parameters) + elif method == 'POST': + request = self._client.post(url, query_parameters) + elif method == 'HEAD': + request = self._client.head(url, query_parameters) + elif method == 'PATCH': + request = self._client.patch(url, query_parameters) + elif method == 'DELETE': + request = self._client.delete(url, query_parameters) + elif method == 'MERGE': + request = self._client.merge(url, query_parameters) + + response = self._client.send(request, header_parameters, body, **operation_config) + + if response.status_code not in expected_status_codes: + exp = CloudError(response) + exp.request_id = response.headers.get('x-ms-request-id') + raise exp + + return response diff --git a/lib/ansible/modules/cloud/azure/azure_rm_resource.py b/lib/ansible/modules/cloud/azure/azure_rm_resource.py new file mode 100644 index 00000000000..5bb015a3559 --- /dev/null +++ b/lib/ansible/modules/cloud/azure/azure_rm_resource.py @@ -0,0 +1,288 @@ +#!/usr/bin/python +# +# Copyright (c) 2018 Zim Kalinowski, +# +# 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 = ''' +--- +module: azure_rm_resource +version_added: "2.6" +short_description: Create any Azure resource. +description: + - Create, update or delete any Azure resource using Azure REST API. + - This module gives access to resources that are not supported via Ansible modules. + - Refer to https://docs.microsoft.com/en-us/rest/api/ regarding details related to specific resource REST API. + +options: + url: + description: + - Azure RM Resource URL. + api_version: + description: + - Specific API version to be used. + required: yes + provider: + description: + - Provider type. + - Required if URL is not specified. + resource_group: + description: + - Resource group to be used. + - Required if URL is not specified. + resource_type: + description: + - Resource type. + - Required if URL is not specified. + resource_name: + description: + - Resource name. + - Required if URL Is not specified. + subresource: + description: + - List of subresources + suboptions: + namespace: + description: + - Subresource namespace + type: + description: + - Subresource type + name: + description: + - Subresource name + body: + description: + - The body of the http request/response to the web service. + method: + description: + - The HTTP method of the request or response. It MUST be uppercase. + choices: [ "GET", "PUT", "POST", "HEAD", "PATCH", "DELETE", "MERGE" ] + default: "PUT" + status_code: + description: + - A valid, numeric, HTTP status code that signifies success of the + request. Can also be comma separated list of status codes. + default: [ 200, 201, 202 ] + idempotency: + description: + - If enabled, idempotency check will be done by using GET method first and then comparing with I(body) + default: no + type: bool + state: + description: + - Assert the state of the resource. Use C(present) to create or update resource or C(absent) to delete resource. + default: present + choices: + - absent + - present + +extends_documentation_fragment: + - azure + +author: + - "Zim Kalinowski (@zikalino)" + +''' + +EXAMPLES = ''' + - name: Update scaleset info using azure_rm_resource + azure_rm_resource: + resource_group: "{{ resource_group }}" + provider: compute + resource_type: virtualmachinescalesets + resource_name: "{{ scaleset_name }}" + api_version: "2017-12-01" + body: "{{ body }}" +''' + +RETURN = ''' +response: + description: Response specific to resource type. + returned: always + type: dict +''' + +from ansible.module_utils.azure_rm_common import AzureRMModuleBase +from ansible.module_utils.azure_rm_common_rest import GenericRestClient +from copy import deepcopy + +try: + from msrestazure.azure_exceptions import CloudError + from msrest.service_client import ServiceClient + from msrestazure.tools import resource_id, is_valid_resource_id + import json + +except ImportError: + # This is handled in azure_rm_common + pass + + +class AzureRMResource(AzureRMModuleBase): + def __init__(self): + # define user inputs into argument + self.module_arg_spec = dict( + url=dict( + type='str', + required=False + ), + provider=dict( + type='str', + ), + resource_group=dict( + type='str', + ), + resource_type=dict( + type='str', + ), + resource_name=dict( + type='str', + ), + subresource=dict( + type='list', + default=[] + ), + api_version=dict( + type='str', + required=True + ), + method=dict( + type='str', + default='PUT', + choices=["GET", "PUT", "POST", "HEAD", "PATCH", "DELETE", "MERGE"] + ), + body=dict( + type='raw' + ), + status_code=dict( + type='list', + default=[200, 201, 202] + ), + idempotency=dict( + type='bool', + default=False + ), + state=dict( + type='str', + default='present', + choices=['present', 'absent'] + ) + ) + # store the results of the module operation + self.results = dict( + changed=False, + response=None + ) + self.mgmt_client = None + self.url = None + self.api_version = None + self.provider = None + self.resource_group = None + self.resource_type = None + self.resource_name = None + self.subresource_type = None + self.subresource_name = None + self.subresource = [] + self.method = None + self.status_code = [] + self.idempotency = False + self.state = None + self.body = None + super(AzureRMResource, self).__init__(self.module_arg_spec, supports_tags=False) + + def exec_module(self, **kwargs): + for key in self.module_arg_spec: + setattr(self, key, kwargs[key]) + self.mgmt_client = self.get_mgmt_svc_client(GenericRestClient, + base_url=self._cloud_environment.endpoints.resource_manager) + + if self.state == 'absent': + self.method = 'DELETE' + self.status_code.append(204) + + if self.url is None: + rargs = dict() + rargs['subscription'] = self.subscription_id + rargs['resource_group'] = self.resource_group + if not (self.provider is None or self.provider.lower().startswith('.microsoft')): + rargs['namespace'] = "Microsoft." + self.provider + else: + rargs['namespace'] = self.provider + rargs['type'] = self.resource_type + rargs['name'] = self.resource_name + + for i in range(len(self.subresource)): + rargs['child_namespace_' + str(i + 1)] = self.subresource[i].get('namespace', None) + rargs['child_type_' + str(i + 1)] = self.subresource[i].get('type', None) + rargs['child_name_' + str(i + 1)] = self.subresource[i].get('name', None) + + self.url = resource_id(**rargs) + + query_parameters = {} + query_parameters['api-version'] = self.api_version + + header_parameters = {} + header_parameters['Content-Type'] = 'application/json; charset=utf-8' + + needs_update = True + response = None + + if self.idempotency: + original = self.mgmt_client.query(self.url, "GET", query_parameters, None, None, [200, 404]) + + if original.status_code == 404: + if self.state == 'absent': + needs_update = False + else: + try: + response = json.loads(original.text) + needs_update = (dict_merge(response, self.body) != response) + except: + pass + + if needs_update: + response = self.mgmt_client.query(self.url, self.method, query_parameters, header_parameters, self.body, self.status_code) + if self.state == 'present': + try: + response = json.loads(response.text) + except: + response = response.text + else: + response = None + + self.results['response'] = response + self.results['changed'] = needs_update + + return self.results + + +def dict_merge(a, b): + '''recursively merges dict's. not just simple a['key'] = b['key'], if + both a and bhave a key who's value is a dict then dict_merge is called + on both values and the result stored in the returned dictionary.''' + if not isinstance(b, dict): + return b + result = deepcopy(a) + for k, v in b.items(): + if k in result and isinstance(result[k], dict): + result[k] = dict_merge(result[k], v) + else: + result[k] = deepcopy(v) + return result + + +def main(): + AzureRMResource() + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/modules/cloud/azure/azure_rm_resource_facts.py b/lib/ansible/modules/cloud/azure/azure_rm_resource_facts.py new file mode 100644 index 00000000000..e5b3eec5bd3 --- /dev/null +++ b/lib/ansible/modules/cloud/azure/azure_rm_resource_facts.py @@ -0,0 +1,203 @@ +#!/usr/bin/python +# +# Copyright (c) 2018 Zim Kalinowski, +# +# 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 = ''' +--- +module: azure_rm_resource_facts +version_added: "2.6" +short_description: Generic facts of Azure resources. +description: + - Obtain facts of any resource using Azure REST API. + - This module gives access to resources that are not supported via Ansible modules. + - Refer to https://docs.microsoft.com/en-us/rest/api/ regarding details related to specific resource REST API. + +options: + url: + description: + - Azure RM Resource URL. + api_version: + description: + - Specific API version to be used. + required: yes + provider: + description: + - Provider type, should be specified in no URL is given + resource_group: + description: + - Resource group to be used. + - Required if URL is not specified. + resource_type: + description: + - Resource type. + resource_name: + description: + - Resource name. + subresource: + description: + - List of subresources + suboptions: + namespace: + description: + - Subresource namespace + type: + description: + - Subresource type + name: + description: + - Subresource name + +extends_documentation_fragment: + - azure + +author: + - "Zim Kalinowski (@zikalino)" + +''' + +EXAMPLES = ''' + - name: Get scaleset info + azure_rm_resource_facts: + resource_group: "{{ resource_group }}" + provider: compute + resource_type: virtualmachinescalesets + resource_name: "{{ scaleset_name }}" + api_version: "2017-12-01" +''' + +RETURN = ''' +response: + description: Response specific to resource type. + returned: always + type: dict +''' + +from ansible.module_utils.azure_rm_common import AzureRMModuleBase +from ansible.module_utils.azure_rm_common_rest import GenericRestClient + +try: + from msrestazure.azure_exceptions import CloudError + from msrest.service_client import ServiceClient + from msrestazure.tools import resource_id, is_valid_resource_id + import json + +except ImportError: + # This is handled in azure_rm_common + pass + + +class AzureRMResourceFacts(AzureRMModuleBase): + def __init__(self): + # define user inputs into argument + self.module_arg_spec = dict( + url=dict( + type='str', + required=False + ), + provider=dict( + type='str', + required=False + ), + resource_group=dict( + type='str', + required=False + ), + resource_type=dict( + type='str', + required=False + ), + resource_name=dict( + type='str', + required=False + ), + subresource=dict( + type='list', + required=False, + default=[] + ), + api_version=dict( + type='str', + required=True + ) + ) + # store the results of the module operation + self.results = dict( + response=None + ) + self.mgmt_client = None + self.url = None + self.api_version = None + self.provider = None + self.resource_group = None + self.resource_type = None + self.resource_name = None + self.subresource = [] + super(AzureRMResourceFacts, self).__init__(self.module_arg_spec, supports_tags=False) + + def exec_module(self, **kwargs): + for key in self.module_arg_spec: + setattr(self, key, kwargs[key]) + self.mgmt_client = self.get_mgmt_svc_client(GenericRestClient, + base_url=self._cloud_environment.endpoints.resource_manager) + + if self.url is None: + rargs = dict() + rargs['subscription'] = self.subscription_id + rargs['resource_group'] = self.resource_group + if not (self.provider is None or self.provider.lower().startswith('.microsoft')): + rargs['namespace'] = "Microsoft." + self.provider + else: + rargs['namespace'] = self.provider + rargs['type'] = self.resource_type + rargs['name'] = self.resource_name + + for i in range(len(self.subresource)): + rargs['child_namespace_' + str(i + 1)] = self.subresource[i].get('namespace', None) + rargs['child_type_' + str(i + 1)] = self.subresource[i].get('type', None) + rargs['child_name_' + str(i + 1)] = self.subresource[i].get('name', None) + + self.url = resource_id(**rargs) + + # this is to fix a problem with resource_id implementation, when resource_name is not specified + if self.resource_type is not None and self.resource_name is None: + self.url += '/' + self.resource_type + + self.results['url'] = self.url + + query_parameters = {} + query_parameters['api-version'] = self.api_version + + header_parameters = {} + header_parameters['Content-Type'] = 'application/json; charset=utf-8' + + response = self.mgmt_client.query(self.url, "GET", query_parameters, header_parameters, None, [200, 404]) + + try: + response = json.loads(response.text) + if response is list: + self.results['response'] = response + else: + self.results['response'] = [response] + except: + self.results['response'] = [] + + return self.results + + +def main(): + AzureRMResourceFacts() + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/azure_rm_resource/aliases b/test/integration/targets/azure_rm_resource/aliases new file mode 100644 index 00000000000..538083df28e --- /dev/null +++ b/test/integration/targets/azure_rm_resource/aliases @@ -0,0 +1,3 @@ +cloud/azure +destructive +posix/ci/cloud/group2/azure diff --git a/test/integration/targets/azure_rm_resource/meta/main.yml b/test/integration/targets/azure_rm_resource/meta/main.yml new file mode 100644 index 00000000000..95e1952f989 --- /dev/null +++ b/test/integration/targets/azure_rm_resource/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_azure diff --git a/test/integration/targets/azure_rm_resource/tasks/main.yml b/test/integration/targets/azure_rm_resource/tasks/main.yml new file mode 100644 index 00000000000..fa4553780ea --- /dev/null +++ b/test/integration/targets/azure_rm_resource/tasks/main.yml @@ -0,0 +1,77 @@ +- name: Prepare random number + set_fact: + rpfx: "{{ resource_group | hash('md5') | truncate(7, True, '') }}{{ 1000 | random }}" + run_once: yes + +- name: Call REST API - StorageAccounts_Create + azure_rm_resource: + api_version: '2017-10-01' + resource_group: "{{ resource_group }}" + provider: storage + resource_type: storageaccounts + resource_name: stacc{{ rpfx }} + body: + sku: + name: Standard_LRS + kind: BlobStorage + location: eastus + properties: + accessTier: Hot + idempotency: yes + register: output + +- name: Assert that something has changed + assert: + that: output.changed + +- name: Call REST API - StorageAccounts_Create - with same parameters + azure_rm_resource: + api_version: '2017-10-01' + resource_group: "{{ resource_group }}" + provider: storage + resource_type: storageaccounts + resource_name: stacc{{ rpfx }} + body: + sku: + name: Standard_LRS + kind: BlobStorage + location: eastus + properties: + accessTier: Hot + idempotency: yes + register: output + +- name: Assert that nothing has changed + assert: + that: not output.changed + +- name: Call REST API - StorageAccounts_Create - with different parameters + azure_rm_resource: + api_version: '2017-10-01' + resource_group: "{{ resource_group }}" + provider: storage + resource_type: storageaccounts + resource_name: stacc{{ rpfx }} + body: + sku: + name: Standard_LRS + kind: BlobStorage + location: eastus + properties: + accessTier: Cool + idempotency: yes + register: output + +- name: Assert that something has changed + assert: + that: output.changed + + +- name: Try to get information about account + azure_rm_resource_facts: + api_version: '2017-10-01' + resource_group: "{{ resource_group }}" + provider: storage + resource_type: storageaccounts + resource_name: stacc{{ rpfx }} + register: output