From 8a6ae51f9065ce8ac78af641216a4c6fed8d4fb5 Mon Sep 17 00:00:00 2001 From: Thomas Stringer Date: Tue, 29 Aug 2017 21:54:58 -0400 Subject: [PATCH] Add support for Azure Functions (#28566) * add template for az func * (wip) add basic azure functions support * add support to add app settings to azure function * add support for updating based off of app settings * add integration tests and refactor required param * support check mode and add facts module * add test for azure functions facts module * add necessary checks and registrations for web client * fix documentation * change return type from complex to dict * disable azure_rm_functionapp tests until stable * remove dict comprehension for py2.6 * pepe has whitespace tumor --- .gitignore | 1 + lib/ansible/module_utils/azure_rm_common.py | 20 +- .../cloud/azure/azure_rm_functionapp.py | 297 ++++++++++++++++++ .../cloud/azure/azure_rm_functionapp_facts.py | 207 ++++++++++++ .../targets/azure_rm_functionapp/aliases | 2 + .../azure_rm_functionapp/meta/main.yml | 2 + .../azure_rm_functionapp/tasks/main.yml | 82 +++++ 7 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 lib/ansible/modules/cloud/azure/azure_rm_functionapp.py create mode 100644 lib/ansible/modules/cloud/azure/azure_rm_functionapp_facts.py create mode 100644 test/integration/targets/azure_rm_functionapp/aliases create mode 100644 test/integration/targets/azure_rm_functionapp/meta/main.yml create mode 100644 test/integration/targets/azure_rm_functionapp/tasks/main.yml diff --git a/.gitignore b/.gitignore index aa351ec62bc..fae5e5f50a5 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,4 @@ htmlcov/ .coverage # ansible-test coverage results test/units/.coverage.* +/test/integration/cloud-config-azure.yml diff --git a/lib/ansible/module_utils/azure_rm_common.py b/lib/ansible/module_utils/azure_rm_common.py index fe00a890d8b..7e3ccaea101 100644 --- a/lib/ansible/module_utils/azure_rm_common.py +++ b/lib/ansible/module_utils/azure_rm_common.py @@ -100,11 +100,13 @@ try: from azure.mgmt.compute.version import VERSION as compute_client_version from azure.mgmt.resource.version import VERSION as resource_client_version from azure.mgmt.dns.version import VERSION as dns_client_version + from azure.mgmt.web.version import VERSION as web_client_version from azure.mgmt.network import NetworkManagementClient from azure.mgmt.resource.resources import ResourceManagementClient from azure.mgmt.storage import StorageManagementClient from azure.mgmt.compute import ComputeManagementClient from azure.mgmt.dns import DnsManagementClient + from azure.mgmt.web import WebSiteManagementClient from azure.mgmt.containerservice import ContainerServiceClient from azure.storage.cloudstorageaccount import CloudStorageAccount except ImportError as exc: @@ -135,7 +137,8 @@ AZURE_EXPECTED_VERSIONS = dict( compute_client_version="1.0.0", network_client_version="1.0.0", resource_client_version="1.1.0", - dns_client_version="1.0.1" + dns_client_version="1.0.1", + web_client_version="0.32.0" ) AZURE_MIN_RELEASE = '2.0.0' @@ -185,7 +188,9 @@ class AzureRMModuleBase(object): self._resource_client = None self._compute_client = None self._dns_client = None + self._web_client = None self._containerservice_client = None + self.check_mode = self.module.check_mode self.facts_module = facts_module # self.debug = self.module.params.get('debug') @@ -727,6 +732,19 @@ class AzureRMModuleBase(object): self._register('Microsoft.Dns') return self._dns_client + @property + def web_client(self): + self.log('Getting web client') + if not self._web_client: + self.check_client_version('web', web_client_version, AZURE_EXPECTED_VERSIONS['web_client_version']) + self._web_client = WebSiteManagementClient( + credentials=self.azure_credentials, + subscription_id=self.subscription_id, + base_url=self.base_url + ) + self._register('Microsoft.Web') + return self._web_client + @property def containerservice_client(self): self.log('Getting container service client') diff --git a/lib/ansible/modules/cloud/azure/azure_rm_functionapp.py b/lib/ansible/modules/cloud/azure/azure_rm_functionapp.py new file mode 100644 index 00000000000..57fb9f4a674 --- /dev/null +++ b/lib/ansible/modules/cloud/azure/azure_rm_functionapp.py @@ -0,0 +1,297 @@ +#!/usr/bin/python +# +# Copyright (c) 2016 Thomas Stringer, +# +# 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_functionapp +version_added: "2.4" +short_description: Manage Azure Function Apps +description: + - Create, update or delete an Azure Function App +options: + resource_group: + description: + - Name of resource group + required: true + name: + description: + - Name of the Azure Function App + required: true + state: + description: + - Assert the state of the Function App. Use 'present' to create or update a Function App and + 'absent' to delete. + required: false + default: present + choices: + - absent + - present + +extends_documentation_fragment: + - azure + +author: + - "Thomas Stringer (@tstringer)" +''' + +EXAMPLES = ''' +- name: create function app + azure_rm_functionapp: + resource_group: ansible-rg + name: myfunctionapp + +- name: create a function app with app settings + azure_rm_functionapp: + resource_group: ansible-rg + name: myfunctionapp + app_settings: + setting1: value1 + setting2: value2 + +- name: delete a function app + azure_rm_functionapp: + name: myfunctionapp + state: absent +''' + +RETURN = ''' +state: + description: Current state of the Azure Function App + returned: success + type: dict + example: + id: /subscriptions/.../resourceGroups/ansible-rg/providers/Microsoft.Web/sites/myfunctionapp + name: myfunctionapp + kind: functionapp + location: East US + type: Microsoft.Web/sites + state: Running + host_names: + - myfunctionapp.azurewebsites.net + repository_site_name: myfunctionapp + usage_state: Normal + enabled: true + enabled_host_names: + - myfunctionapp.azurewebsites.net + - myfunctionapp.scm.azurewebsites.net + availability_state: Normal + host_name_ssl_states: + - name: myfunctionapp.azurewebsites.net + ssl_state: Disabled + host_type: Standard + - name: myfunctionapp.scm.azurewebsites.net + ssl_state: Disabled + host_type: Repository + server_farm_id: /subscriptions/.../resourceGroups/ansible-rg/providers/Microsoft.Web/serverfarms/EastUSPlan + reserved: false + last_modified_time_utc: 2017-08-22T18:54:01.190Z + scm_site_also_stopped: false + client_affinity_enabled: true + client_cert_enabled: false + host_names_disabled: false + outbound_ip_addresses: ............ + container_size: 1536 + daily_memory_time_quota: 0 + resource_group: ansible-rg + default_host_name: myfunctionapp.azurewebsites.net +''' # NOQA + +from ansible.module_utils.azure_rm_common import AzureRMModuleBase + +try: + from msrestazure.azure_exceptions import CloudError + from azure.mgmt.web.models import Site, SiteConfig, NameValuePair, SiteSourceControl + from azure.mgmt.resource.resources import ResourceManagementClient +except ImportError: + # This is handled in azure_rm_common + pass + + +class AzureRMFunctionApp(AzureRMModuleBase): + + def __init__(self): + + self.module_arg_spec = dict( + resource_group=dict(type='str', required=True, aliases=['resource_group_name']), + name=dict(type='str', required=True), + state=dict(type='str', default='present', choices=['present', 'absent']), + location=dict(type='str', required=False), + storage_account=dict( + type='str', + required=False, + aliases=['storage', 'storage_account_name'] + ), + app_settings=dict(type='dict') + ) + + self.results = dict( + changed=False, + state=dict() + ) + + self.resource_group = None + self.name = None + self.state = None + self.location = None + self.storage_account = None + self.app_settings = None + + required_if = [('state', 'present', ['storage_account'])] + + super(AzureRMFunctionApp, self).__init__( + self.module_arg_spec, + supports_check_mode=True, + required_if=required_if + ) + + def exec_module(self, **kwargs): + + for key in self.module_arg_spec: + setattr(self, key, kwargs[key]) + if self.app_settings is None: + self.app_settings = dict() + + try: + resource_group = self.rm_client.resource_groups.get(self.resource_group) + except CloudError: + self.fail('Unable to retrieve resource group') + + self.location = self.location or resource_group.location + + try: + function_app = self.web_client.web_apps.get( + resource_group_name=self.resource_group, + name=self.name + ) + exists = True + except CloudError as exc: + exists = False + + if self.state == 'absent': + if exists: + if self.check_mode: + self.results['changed'] = True + return self.results + try: + self.web_client.web_apps.delete( + resource_group_name=self.resource_group, + name=self.name + ) + self.results['changed'] = True + except CloudError as exc: + self.fail('Failure while deleting web app: {}'.format(exc)) + else: + self.results['changed'] = False + else: + if not exists: + function_app = Site( + location=self.location, + kind='functionapp', + site_config=SiteConfig( + app_settings=self.aggregated_app_settings(), + scm_type='LocalGit' + ) + ) + self.results['changed'] = True + else: + self.results['changed'], function_app = self.update(function_app) + + if self.check_mode: + self.results['state'] = function_app.as_dict() + elif self.results['changed']: + try: + new_function_app = self.web_client.web_apps.create_or_update( + resource_group_name=self.resource_group, + name=self.name, + site_envelope=function_app + ).result() + self.results['state'] = new_function_app.as_dict() + except CloudError as exc: + self.fail('Error creating or updating web app: {}'.format(exc)) + + return self.results + + def update(self, source_function_app): + """Update the Site object if there are any changes""" + + source_app_settings = self.web_client.web_apps.list_application_settings( + resource_group_name=self.resource_group, + name=self.name + ) + + changed, target_app_settings = self.update_app_settings(source_app_settings.properties) + + source_function_app.site_config = SiteConfig( + app_settings=target_app_settings, + scm_type='LocalGit' + ) + + return changed, source_function_app + + def update_app_settings(self, source_app_settings): + """Update app settings""" + + target_app_settings = self.aggregated_app_settings() + target_app_settings_dict = dict([(i.name, i.value) for i in target_app_settings]) + return target_app_settings_dict != source_app_settings, target_app_settings + + def necessary_functionapp_settings(self): + """Construct the necessary app settings required for an Azure Function App""" + + function_app_settings = [] + for key in ['AzureWebJobsStorage', 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING', 'AzureWebJobsDashboard']: + function_app_settings.append(NameValuePair(name=key, value=self.storage_connection_string)) + function_app_settings.append(NameValuePair(name='FUNCTIONS_EXTENSION_VERSION', value='~1')) + function_app_settings.append(NameValuePair(name='WEBSITE_NODE_DEFAULT_VERSION', value='6.5.0')) + function_app_settings.append(NameValuePair(name='WEBSITE_CONTENTSHARE', value=self.storage_account)) + return function_app_settings + + def aggregated_app_settings(self): + """Combine both system and user app settings""" + + function_app_settings = self.necessary_functionapp_settings() + for app_setting_key in self.app_settings: + function_app_settings.append(NameValuePair( + name=app_setting_key, + value=self.app_settings[app_setting_key] + )) + return function_app_settings + + @property + def storage_connection_string(self): + """Construct the storage account connection string""" + + return 'DefaultEndpointsProtocol=https;AccountName={};AccountKey={}'.format( + self.storage_account, + self.storage_key + ) + + @property + def storage_key(self): + """Retrieve the storage account key""" + + return self.storage_client.storage_accounts.list_keys( + resource_group_name=self.resource_group, + account_name=self.storage_account + ).keys[0].value + + +def main(): + """Main function execution""" + + AzureRMFunctionApp() + +if __name__ == '__main__': + main() diff --git a/lib/ansible/modules/cloud/azure/azure_rm_functionapp_facts.py b/lib/ansible/modules/cloud/azure/azure_rm_functionapp_facts.py new file mode 100644 index 00000000000..b65af48e23b --- /dev/null +++ b/lib/ansible/modules/cloud/azure/azure_rm_functionapp_facts.py @@ -0,0 +1,207 @@ +#!/usr/bin/python +# +# Copyright (c) 2016 Thomas Stringer, + +# 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_functionapp_facts +version_added: "2.4" +short_description: Get Azure Function App facts +description: + - Get facts for one Azure Function App or all Function Apps within a resource group +options: + name: + description: + - Only show results for a specific Function App + required: false + default: null + resource_group: + description: + - Limit results to a resource group. Required when filtering by name + required: false + default: null + aliases: + - resource_group_name + tags: + description: + - Limit results by providing a list of tags. Format tags as 'key' or 'key:value'. + required: false + default: null + +extends_documentation_fragment: + - azure + +author: + - "Thomas Stringer (@tstringer)" +''' + +EXAMPLES = ''' + - name: Get facts for one Function App + azure_rm_functionapp_facts: + resource_group: ansible-rg + name: myfunctionapp + + - name: Get facts for all Function Apps in a resource group + azure_rm_functionapp_facts: + resource_group: ansible-rg + + - name: Get facts for all Function Apps by tags + azure_rm_functionapp_facts: + tags: + - testing +''' + +RETURN = ''' +azure_functionapps: + description: List of Azure Function Apps dicts + returned: always + type: list + example: + id: /subscriptions/.../resourceGroups/ansible-rg/providers/Microsoft.Web/sites/myfunctionapp + name: myfunctionapp + kind: functionapp + location: East US + type: Microsoft.Web/sites + state: Running + host_names: + - myfunctionapp.azurewebsites.net + repository_site_name: myfunctionapp + usage_state: Normal + enabled: true + enabled_host_names: + - myfunctionapp.azurewebsites.net + - myfunctionapp.scm.azurewebsites.net + availability_state: Normal + host_name_ssl_states: + - name: myfunctionapp.azurewebsites.net + ssl_state: Disabled + host_type: Standard + - name: myfunctionapp.scm.azurewebsites.net + ssl_state: Disabled + host_type: Repository + server_farm_id: /subscriptions/.../resourceGroups/ansible-rg/providers/Microsoft.Web/serverfarms/EastUSPlan + reserved: false + last_modified_time_utc: 2017-08-22T18:54:01.190Z + scm_site_also_stopped: false + client_affinity_enabled: true + client_cert_enabled: false + host_names_disabled: false + outbound_ip_addresses: ............ + container_size: 1536 + daily_memory_time_quota: 0 + resource_group: ansible-rg + default_host_name: myfunctionapp.azurewebsites.net +''' + +try: + from msrestazure.azure_exceptions import CloudError +except: + # This is handled in azure_rm_common + pass + +from ansible.module_utils.azure_rm_common import AzureRMModuleBase + + +class AzureRMFunctionAppFacts(AzureRMModuleBase): + def __init__(self): + + self.module_arg_spec = dict( + name=dict(type='str'), + resource_group=dict(type='str', aliases=['resource_group_name']), + tags=dict(type='list'), + ) + + self.results = dict( + changed=False, + ansible_facts=dict(azure_functionapps=[]) + ) + + self.name = None + self.resource_group = None + self.tags = None + + super(AzureRMFunctionAppFacts, self).__init__( + self.module_arg_spec, + supports_tags=False, + facts_module=True + ) + + def exec_module(self, **kwargs): + + for key in self.module_arg_spec: + setattr(self, key, kwargs[key]) + + if self.name and not self.resource_group: + self.fail("Parameter error: resource group required when filtering by name.") + + if self.name: + self.results['ansible_facts']['azure_functionapps'] = self.get_functionapp() + elif self.resource_group: + self.results['ansible_facts']['azure_functionapps'] = self.list_resource_group() + else: + self.results['ansible_facts']['azure_functionapps'] = self.list_all() + + return self.results + + def get_functionapp(self): + self.log('Get properties for Function App {0}'.format(self.name)) + function_app = None + result = [] + + try: + function_app = self.web_client.web_apps.get( + self.resource_group, + self.name + ) + except CloudError: + pass + + if function_app and self.has_tags(function_app.tags, self.tags): + result = function_app.as_dict() + + return [result] + + def list_resource_group(self): + self.log('List items') + try: + response = self.web_client.web_apps.list_by_resource_group(self.resource_group) + except Exception as exc: + self.fail("Error listing for resource group {0} - {1}".format(self.resource_group, str(exc))) + + results = [] + for item in response: + if self.has_tags(item.tags, self.tags): + results.append(item.as_dict()) + return results + + def list_all(self): + self.log('List all items') + try: + response = self.web_client.web_apps.list_by_resource_group(self.resource_group) + except Exception as exc: + self.fail("Error listing all items - {0}".format(str(exc))) + + results = [] + for item in response: + if self.has_tags(item.tags, self.tags): + results.append(item.as_dict()) + return results + + +def main(): + AzureRMFunctionAppFacts() + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/azure_rm_functionapp/aliases b/test/integration/targets/azure_rm_functionapp/aliases new file mode 100644 index 00000000000..b1cd4a5978c --- /dev/null +++ b/test/integration/targets/azure_rm_functionapp/aliases @@ -0,0 +1,2 @@ +cloud/azure +destructive diff --git a/test/integration/targets/azure_rm_functionapp/meta/main.yml b/test/integration/targets/azure_rm_functionapp/meta/main.yml new file mode 100644 index 00000000000..95e1952f989 --- /dev/null +++ b/test/integration/targets/azure_rm_functionapp/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_azure diff --git a/test/integration/targets/azure_rm_functionapp/tasks/main.yml b/test/integration/targets/azure_rm_functionapp/tasks/main.yml new file mode 100644 index 00000000000..852566fb143 --- /dev/null +++ b/test/integration/targets/azure_rm_functionapp/tasks/main.yml @@ -0,0 +1,82 @@ +- name: create storage account for function apps + azure_rm_storageaccount: + resource_group: '{{ resource_group }}' + name: azfunccistor4 + account_type: Standard_LRS + +- name: create basic function app + azure_rm_functionapp: + resource_group: '{{ resource_group }}' + name: azfuncci + storage_account: azfunccistor4 + register: output + +- name: assert the function was created + assert: + that: output.changed + +- name: list facts for function + azure_rm_functionapp_facts: + resource_group: '{{ resource_group }}' + name: azfuncci + +- name: assert the facts were retrieved + assert: + that: '{{ ansible_facts.azure_functionapps|length == 1 }}' + +- name: delete basic function app + azure_rm_functionapp: + resource_group: '{{ resource_group }}' + name: azfuncci + state: absent + register: output + +- name: assert the function was deleted + assert: + that: output.changed + +- name: create a function with app settings + azure_rm_functionapp: + resource_group: '{{ resource_group }}' + name: azfuncci + storage_account: azfunccistor4 + app_settings: + hello: world + things: more stuff + register: output + +- name: assert the function with app settings was created + assert: + that: output.changed + +- name: change app settings + azure_rm_functionapp: + resource_group: '{{ resource_group }}' + name: azfuncci + storage_account: azfunccistor4 + app_settings: + hello: world + things: more stuff + another: one + register: output + +- name: assert the function was changed + assert: + that: output.changed + +- name: delete the function app + azure_rm_functionapp: + resource_group: '{{ resource_group }}' + name: azfuncci + state: absent + register: output + +- name: assert the function was deleted + assert: + that: output.changed + +- name: delete storage account + azure_rm_storageaccount: + resource_group: '{{ resource_group }}' + name: azfunccistor4 + state: absent