diff --git a/cloud/azure/azure_deployment.py b/cloud/azure/azure_deployment.py new file mode 100644 index 00000000000..4e97b0b694e --- /dev/null +++ b/cloud/azure/azure_deployment.py @@ -0,0 +1,672 @@ +#!/usr/bin/python +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +DOCUMENTATION = ''' +--- +module: azure_deployment +short_description: Create or destroy Azure Resource Manager template deployments +version_added: "2.0" +description: + - Create or destroy Azure Resource Manager template deployments via the Azure SDK for Python. + You can find some quick start templates in GitHub here: https://github.com/azure/azure-quickstart-templates. + If you would like to find out more information about Azure Resource Manager templates, see: https://azure.microsoft.com/en-us/documentation/articles/resource-group-template-deploy/. +options: + subscription_id: + description: + - The Azure subscription to deploy the template into. + required: true + resource_group_name: + description: + - The resource group name to use or create to host the deployed template + required: true + state: + description: + - If state is "present", template will be created. If state is "present" and if deployment exists, it will be updated. + If state is "absent", stack will be removed. + required: true + template: + description: + - A hash containg the templates inline. This parameter is mutually exclusive with 'template_link'. + Either one of them is required if "state" parameter is "present". + required: false + default: None + template_link: + description: + - Uri of file containing the template body. This parameter is mutually exclusive with 'template'. Either one + of them is required if "state" parameter is "present". + required: false + default: None + parameters: + description: + - A hash of all the required template variables for the deployment template. This parameter is mutually exclusive with 'parameters_link'. + Either one of them is required if "state" parameter is "present". + required: false + default: None + parameters_link: + description: + - Uri of file containing the parameters body. This parameter is mutually exclusive with 'parameters'. Either + one of them is required if "state" parameter is "present". + required: false + default: None + location: + description: + - The geo-locations in which the resource group will be located. + require: false + default: West US + +author: "David Justice (@devigned)" +''' + +EXAMPLES = ''' +# Destroy a template deployment +- name: Destroy Azure Deploy + azure_deploy: + state: absent + subscription_id: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + resource_group_name: dev-ops-cle + +# Create or update a template deployment based on uris to paramters and a template +- name: Create Azure Deploy + azure_deploy: + state: present + subscription_id: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + resource_group_name: dev-ops-cle + parameters_link: 'https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/101-simple-linux-vm/azuredeploy.parameters.json' + template_link: 'https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/101-simple-linux-vm/azuredeploy.json' + +# Create or update a template deployment based on a uri to the template and parameters specified inline. +# This deploys a VM with SSH support for a given public key, then stores the result in 'azure_vms'. The result is then used +# to create a new host group. This host group is then used to wait for each instance to respond to the public IP SSH. +--- +- hosts: localhost + tasks: + - name: Destroy Azure Deploy + azure_deployment: + state: absent + subscription_id: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + resource_group_name: dev-ops-cle + + - name: Create Azure Deploy + azure_deployment: + state: present + subscription_id: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + resource_group_name: dev-ops-cle + parameters: + newStorageAccountName: + value: devopsclestorage1 + adminUsername: + value: devopscle + dnsNameForPublicIP: + value: devopscleazure + location: + value: West US + vmSize: + value: Standard_A2 + vmName: + value: ansibleSshVm + sshKeyData: + value: YOUR_SSH_PUBLIC_KEY + template_link: 'https://raw.githubusercontent.com/Azure/azure-quickstart-templates/master/101-vm-sshkey/azuredeploy.json' + register: azure + - name: Add new instance to host group + add_host: hostname={{ item['ips'][0].public_ip }} groupname=azure_vms + with_items: azure.instances + +- hosts: azure_vms + user: devopscle + tasks: + - name: Wait for SSH to come up + wait_for: port=22 timeout=2000 state=started + - name: echo the hostname of the vm + shell: hostname + +# Deploy an Azure WebApp running a hello world'ish node app +- name: Create Azure WebApp Deployment at http://devopscleweb.azurewebsites.net/hello.js + azure_deployment: + state: present + subscription_id: cbbdaed0-fea9-4693-bf0c-d446ac93c030 + resource_group_name: dev-ops-cle-webapp + parameters: + repoURL: + value: 'https://github.com/devigned/az-roadshow-oss.git' + siteName: + value: devopscleweb + hostingPlanName: + value: someplan + siteLocation: + value: westus + sku: + value: Standard + template_link: 'https://raw.githubusercontent.com/azure/azure-quickstart-templates/master/201-web-app-github-deploy/azuredeploy.json' + +# Create or update a template deployment based on an inline template and parameters +- name: Create Azure Deploy + azure_deploy: + state: present + subscription_id: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + resource_group_name: dev-ops-cle + + template: + $schema: "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#" + contentVersion: "1.0.0.0" + parameters: + newStorageAccountName: + type: "string" + metadata: + description: "Unique DNS Name for the Storage Account where the Virtual Machine's disks will be placed." + adminUsername: + type: "string" + metadata: + description: "User name for the Virtual Machine." + adminPassword: + type: "securestring" + metadata: + description: "Password for the Virtual Machine." + dnsNameForPublicIP: + type: "string" + metadata: + description: "Unique DNS Name for the Public IP used to access the Virtual Machine." + ubuntuOSVersion: + type: "string" + defaultValue: "14.04.2-LTS" + allowedValues: + - "12.04.5-LTS" + - "14.04.2-LTS" + - "15.04" + metadata: + description: "The Ubuntu version for the VM. This will pick a fully patched image of this given Ubuntu version. Allowed values: 12.04.5-LTS, 14.04.2-LTS, 15.04." + variables: + location: "West US" + imagePublisher: "Canonical" + imageOffer: "UbuntuServer" + OSDiskName: "osdiskforlinuxsimple" + nicName: "myVMNic" + addressPrefix: "10.0.0.0/16" + subnetName: "Subnet" + subnetPrefix: "10.0.0.0/24" + storageAccountType: "Standard_LRS" + publicIPAddressName: "myPublicIP" + publicIPAddressType: "Dynamic" + vmStorageAccountContainerName: "vhds" + vmName: "MyUbuntuVM" + vmSize: "Standard_D1" + virtualNetworkName: "MyVNET" + vnetID: "[resourceId('Microsoft.Network/virtualNetworks',variables('virtualNetworkName'))]" + subnetRef: "[concat(variables('vnetID'),'/subnets/',variables('subnetName'))]" + resources: + - + type: "Microsoft.Storage/storageAccounts" + name: "[parameters('newStorageAccountName')]" + apiVersion: "2015-05-01-preview" + location: "[variables('location')]" + properties: + accountType: "[variables('storageAccountType')]" + - + apiVersion: "2015-05-01-preview" + type: "Microsoft.Network/publicIPAddresses" + name: "[variables('publicIPAddressName')]" + location: "[variables('location')]" + properties: + publicIPAllocationMethod: "[variables('publicIPAddressType')]" + dnsSettings: + domainNameLabel: "[parameters('dnsNameForPublicIP')]" + - + type: "Microsoft.Network/virtualNetworks" + apiVersion: "2015-05-01-preview" + name: "[variables('virtualNetworkName')]" + location: "[variables('location')]" + properties: + addressSpace: + addressPrefixes: + - "[variables('addressPrefix')]" + subnets: + - + name: "[variables('subnetName')]" + properties: + addressPrefix: "[variables('subnetPrefix')]" + - + type: "Microsoft.Network/networkInterfaces" + apiVersion: "2015-05-01-preview" + name: "[variables('nicName')]" + location: "[variables('location')]" + dependsOn: + - "[concat('Microsoft.Network/publicIPAddresses/', variables('publicIPAddressName'))]" + - "[concat('Microsoft.Network/virtualNetworks/', variables('virtualNetworkName'))]" + properties: + ipConfigurations: + - + name: "ipconfig1" + properties: + privateIPAllocationMethod: "Dynamic" + publicIPAddress: + id: "[resourceId('Microsoft.Network/publicIPAddresses',variables('publicIPAddressName'))]" + subnet: + id: "[variables('subnetRef')]" + - + type: "Microsoft.Compute/virtualMachines" + apiVersion: "2015-06-15" + name: "[variables('vmName')]" + location: "[variables('location')]" + dependsOn: + - "[concat('Microsoft.Storage/storageAccounts/', parameters('newStorageAccountName'))]" + - "[concat('Microsoft.Network/networkInterfaces/', variables('nicName'))]" + properties: + hardwareProfile: + vmSize: "[variables('vmSize')]" + osProfile: + computername: "[variables('vmName')]" + adminUsername: "[parameters('adminUsername')]" + adminPassword: "[parameters('adminPassword')]" + storageProfile: + imageReference: + publisher: "[variables('imagePublisher')]" + offer: "[variables('imageOffer')]" + sku: "[parameters('ubuntuOSVersion')]" + version: "latest" + osDisk: + name: "osdisk" + vhd: + uri: "[concat('http://',parameters('newStorageAccountName'),'.blob.core.windows.net/',variables('vmStorageAccountContainerName'),'/',variables('OSDiskName'),'.vhd')]" + caching: "ReadWrite" + createOption: "FromImage" + networkProfile: + networkInterfaces: + - + id: "[resourceId('Microsoft.Network/networkInterfaces',variables('nicName'))]" + diagnosticsProfile: + bootDiagnostics: + enabled: "true" + storageUri: "[concat('http://',parameters('newStorageAccountName'),'.blob.core.windows.net')]" + parameters: + newStorageAccountName: + value: devopsclestorage + adminUsername: + value: devopscle + adminPassword: + value: Password1! + dnsNameForPublicIP: + value: devopscleazure +''' + +try: + import time + import yaml + import requests + import azure + from itertools import chain + from azure.mgmt.common import SubscriptionCloudCredentials + from azure.mgmt.resource import ResourceManagementClient + + HAS_DEPS = True +except ImportError: + HAS_DEPS = False + +AZURE_URL = "https://management.azure.com" +DEPLOY_URL_FORMAT = "/subscriptions/{}/resourcegroups/{}/providers/microsoft.resources/deployments/{}?api-version={}" +RES_GROUP_URL_FORMAT = "/subscriptions/{}/resourcegroups/{}?api-version={}" +ARM_API_VERSION = "2015-01-01" +NETWORK_API_VERSION = "2015-06-15" + + +def get_token(domain_or_tenant, client_id, client_secret): + """ + Get an Azure Active Directory token for a service principal + :param domain_or_tenant: The domain or tenant id of your Azure Active Directory instance + :param client_id: The client id of your application in Azure Active Directory + :param client_secret: One of the application secrets created in your Azure Active Directory application + :return: an authenticated bearer token to be used with requests to the API + """ + # the client id we can borrow from azure xplat cli + grant_type = 'client_credentials' + token_url = 'https://login.microsoftonline.com/{}/oauth2/token'.format(domain_or_tenant) + + payload = { + 'grant_type': grant_type, + 'client_id': client_id, + 'client_secret': client_secret, + 'resource': 'https://management.core.windows.net/' + } + + res = requests.post(token_url, data=payload) + return res.json()['access_token'] if res.status_code == 200 else None + + +def get_azure_connection_info(module): + azure_url = module.params.get('azure_url') + tenant_or_domain = module.params.get('tenant_or_domain') + client_id = module.params.get('client_id') + client_secret = module.params.get('client_secret') + security_token = module.params.get('security_token') + resource_group_name = module.params.get('resource_group_name') + subscription_id = module.params.get('subscription_id') + + if not azure_url: + if 'AZURE_URL' in os.environ: + azure_url = os.environ['AZURE_URL'] + else: + azure_url = None + + if not subscription_id: + if 'AZURE_SUBSCRIPTION_ID' in os.environ: + subscription_id = os.environ['AZURE_SUBSCRIPTION_ID'] + else: + subscription_id = None + + if not resource_group_name: + if 'AZURE_RESOURCE_GROUP_NAME' in os.environ: + resource_group_name = os.environ['AZURE_RESOURCE_GROUP_NAME'] + else: + resource_group_name = None + + if not security_token: + if 'AZURE_SECURITY_TOKEN' in os.environ: + security_token = os.environ['AZURE_SECURITY_TOKEN'] + else: + security_token = None + + if not tenant_or_domain: + if 'AZURE_TENANT_ID' in os.environ: + tenant_or_domain = os.environ['AZURE_TENANT_ID'] + elif 'AZURE_DOMAIN' in os.environ: + tenant_or_domain = os.environ['AZURE_DOMAIN'] + else: + tenant_or_domain = None + + if not client_id: + if 'AZURE_CLIENT_ID' in os.environ: + client_id = os.environ['AZURE_CLIENT_ID'] + else: + client_id = None + + if not client_secret: + if 'AZURE_CLIENT_SECRET' in os.environ: + client_secret = os.environ['AZURE_CLIENT_SECRET'] + else: + client_secret = None + + return dict(azure_url=azure_url, + tenant_or_domain=tenant_or_domain, + client_id=client_id, + client_secret=client_secret, + security_token=security_token, + resource_group_name=resource_group_name, + subscription_id=subscription_id) + + +def build_deployment_body(module): + """ + Build the deployment body from the module parameters + :param module: Ansible module containing the validated configuration for the deployment template + :return: body as dict + """ + properties = dict(mode='Incremental') + properties['templateLink'] = \ + dict(uri=module.params.get('template_link'), + contentVersion=module.params.get('content_version')) + + properties['parametersLink'] = \ + dict(uri=module.params.get('parameters_link'), + contentVersion=module.params.get('content_version')) + + return dict(properties=properties) + + +def follow_deployment(client, group_name, deployment): + state = deployment.properties.provisioning_state + if state == azure.mgmt.common.OperationStatus.Failed or \ + state == azure.mgmt.common.OperationStatus.Succeeded or \ + state == "Canceled" or \ + state == "Deleted": + return deployment + else: + time.sleep(30) + result = client.deployments.get(group_name, deployment.name) + return follow_deployment(client, group_name, result.deployment) + + +def follow_delete(client, location): + result = client.get_long_running_operation_status(location) + if result.status == azure.mgmt.common.OperationStatus.Succeeded: + return True + elif result.status == azure.mgmt.common.OperationStatus.Failed: + return False + else: + time.sleep(30) + return follow_delete(client, location) + + +def deploy_template(module, client, conn_info): + """ + Deploy the targeted template and parameters + :param module: Ansible module containing the validated configuration for the deployment template + :param client: resource management client for azure + :param conn_info: connection info needed + :return: + """ + + deployment_name = conn_info["deployment_name"] + group_name = conn_info["resource_group_name"] + + deploy_parameter = azure.mgmt.resource.DeploymentProperties() + deploy_parameter.mode = "Complete" + + if module.params.get('parameters_link') is None: + deploy_parameter.parameters = json.dumps(module.params.get('parameters'), ensure_ascii=False) + else: + parameters_link = azure.mgmt.resource.ParametersLink() + parameters_link.uri = module.params.get('parameters_link') + deploy_parameter.parameters_link = parameters_link + + if module.params.get('template_link') is None: + deploy_parameter.template = json.dumps(module.params.get('template'), ensure_ascii=False) + else: + template_link = azure.mgmt.resource.TemplateLink() + template_link.uri = module.params.get('template_link') + deploy_parameter.template_link = template_link + + deployment = azure.mgmt.resource.Deployment(properties=deploy_parameter) + params = azure.mgmt.resource.ResourceGroup(location=module.params.get('location'), tags=module.params.get('tags')) + try: + client.resource_groups.create_or_update(group_name, params) + result = client.deployments.create_or_update(group_name, deployment_name, deployment) + return follow_deployment(client, group_name, result.deployment) + except azure.common.AzureHttpError as e: + module.fail_json(msg='Deploy create failed with status code: %s and message: "%s"' % (e.status_code, e.message)) + + +def deploy_url(subscription_id, resource_group_name, deployment_name, api_version=ARM_API_VERSION): + return AZURE_URL + DEPLOY_URL_FORMAT.format(subscription_id, resource_group_name, deployment_name, api_version) + + +def res_group_url(subscription_id, resource_group_name, api_version=ARM_API_VERSION): + return AZURE_URL + RES_GROUP_URL_FORMAT.format(subscription_id, resource_group_name, api_version) + + +def default_headers(token, with_content=False): + headers = {'Authorization': 'Bearer {}'.format(token), 'Accept': 'application/json'} + if with_content: + headers['Content-Type'] = 'application/json' + return headers + + +def destroy_resource_group(module, client, conn_info): + """ + Destroy the targeted resource group + :param module: ansible module + :param client: resource management client for azure + :param conn_info: connection info needed + :return: if the result caused a change in the deployment + """ + + try: + client.resource_groups.get(conn_info['resource_group_name']) + except azure.common.AzureMissingResourceHttpError: + return False + + try: + url = res_group_url(conn_info['subscription_id'], conn_info['resource_group_name']) + res = requests.delete(url, headers=default_headers(conn_info['security_token'])) + if res.status_code == 404 or res.status_code == 204: + return False + + if res.status_code == 202: + location = res.headers['location'] + follow_delete(client, location) + return True + + if res.status_code == requests.codes.ok: + return True + else: + module.fail_json( + msg='Delete resource group and deploy failed with status code: %s and message: %s' % (res.status_code, res.text)) + except azure.common.AzureHttpError as e: + if e.status_code == 404 or e.status_code == 204: + return True + else: + module.fail_json( + msg='Delete resource group and deploy failed with status code: %s and message: %s' % (e.status_code, e.message)) + + +def get_dependencies(dep_tree, resource_type): + matches = [value for value in dep_tree.values() if value['dep'].resource_type == resource_type] + for child_tree in [value['children'] for value in dep_tree.values()]: + matches += get_dependencies(child_tree, resource_type) + return matches + + +def build_hierarchy(module, dependencies, tree=None): + tree = dict(top=True) if tree is None else tree + for dep in dependencies: + if dep.resource_name not in tree: + tree[dep.resource_name] = dict(dep=dep, children=dict()) + if isinstance(dep, azure.mgmt.resource.Dependency) and dep.depends_on is not None and len(dep.depends_on) > 0: + build_hierarchy(module, dep.depends_on, tree[dep.resource_name]['children']) + + if 'top' in tree: + tree.pop('top', None) + keys = list(tree.keys()) + for key1 in keys: + for key2 in keys: + if key2 in tree and key1 in tree[key2]['children']: + tree[key2]['children'][key1] = tree[key1] + tree.pop(key1) + return tree + + +class ResourceId: + def __init__(self, **kwargs): + self.resource_name = kwargs.get('resource_name') + self.resource_provider_api_version = kwargs.get('api_version') + self.resource_provider_namespace = kwargs.get('resource_namespace') + self.resource_type = kwargs.get('resource_type') + self.parent_resource_path = kwargs.get('parent_resource_path') + pass + + +def get_resource_details(client, group, name, namespace, resource_type, api_version): + res_id = ResourceId(resource_name=name, api_version=api_version, resource_namespace=namespace, + resource_type=resource_type) + return client.resources.get(group, res_id).resource + + +def get_ip_dict(ip): + p = json.loads(ip.properties) + d = p['dnsSettings'] + return dict(name=ip.name, + id=ip.id, + public_ip=p['ipAddress'], + public_ip_allocation_method=p['publicIPAllocationMethod'], + dns_settings=d) + + +def get_instances(module, client, group, deployment): + dep_tree = build_hierarchy(module, deployment.properties.dependencies) + vms = get_dependencies(dep_tree, resource_type="Microsoft.Compute/virtualMachines") + + vms_and_ips = [(vm, get_dependencies(vm['children'], "Microsoft.Network/publicIPAddresses")) for vm in vms] + vms_and_ips = [(vm['dep'], [get_resource_details(client, + group, + ip['dep'].resource_name, + "Microsoft.Network", + "publicIPAddresses", + NETWORK_API_VERSION) for ip in ip_list]) for vm, ip_list in vms_and_ips if len(ip_list) > 0] + + return [dict(vm_name=vm.resource_name, ips=[get_ip_dict(ip) for ip in ips]) for vm, ips in vms_and_ips] + + +def main(): + argument_spec = dict( + azure_url=dict(default=AZURE_URL), + subscription_id=dict(required=True), + client_secret=dict(no_log=True), + client_id=dict(), + tenant_or_domain=dict(), + security_token=dict(aliases=['access_token'], no_log=True), + resource_group_name=dict(required=True), + state=dict(default='present', choices=['present', 'absent']), + template=dict(default=None, type='dict'), + parameters=dict(default=None, type='dict'), + template_link=dict(default=None), + parameters_link=dict(default=None), + location=dict(default="West US") + ) + + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=[['template_link', 'template'], ['parameters_link', 'parameters']], + ) + + if not HAS_DEPS: + module.fail_json(msg='requests and azure are required for this module') + + conn_info = get_azure_connection_info(module) + + if conn_info['security_token'] is None and \ + (conn_info['client_id'] is None or conn_info['client_secret'] is None or conn_info[ + 'tenant_or_domain'] is None): + module.fail_json(msg='security token or client_id, client_secret and tenant_or_domain is required') + + if conn_info['security_token'] is None: + conn_info['security_token'] = get_token(conn_info['tenant_or_domain'], + conn_info['client_id'], + conn_info['client_secret']) + + if conn_info['security_token'] is None: + module.fail_json(msg='failed to retrieve a security token from Azure Active Directory') + + credentials = SubscriptionCloudCredentials(module.params.get('subscription_id'), conn_info['security_token']) + resource_client = ResourceManagementClient(credentials) + conn_info['deployment_name'] = 'ansible-arm' + + if module.params.get('state') == 'present': + deployment = deploy_template(module, resource_client, conn_info) + data = dict(name=deployment.name, + group_name=conn_info['resource_group_name'], + id=deployment.id, + outputs=deployment.properties.outputs, + instances=get_instances(module, resource_client, conn_info['resource_group_name'], deployment), + changed=True, + msg='deployment created') + module.exit_json(**data) + else: + destroy_resource_group(module, resource_client, conn_info) + module.exit_json(changed=True, msg='deployment deleted') + + +# import module snippets +from ansible.module_utils.basic import * + +if __name__ == '__main__': + main()