diff --git a/library/cloud/azure b/library/cloud/azure new file mode 100644 index 00000000000..c6210a8536f --- /dev/null +++ b/library/cloud/azure @@ -0,0 +1,399 @@ +#!/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 +short_description: create or terminate a virtual machine in azure +description: + - Creates or terminates azure instances. When created optionally waits for it to be 'running'. This module has a dependency on python-azure >= 0.7.1 +version_added: "1.5" +options: + name: + description: + - name of the virtual machine and associated cloud service. + required: true + default: null + location: + description: + - the azure location to use (e.g. 'East US') + required: true + default: null + subscription_id: + description: + - azure subscription id. Overrides the AZURE_SUBSCRIPTION_ID environement variable. + required: false + default: null + management_cert_path: + description: + - path to an azure management certificate associated with the subscription id. Overrides the AZURE_MANAGEMENT_CERT_PATH environement variable. + required: false + default: null + storage_account: + description: + - the azure storage account in which to store the data disks. + required: true + image: + description: + - system image for creating the virtual machine (e.g., b39f27a8b8c64d52b05eac6a62ebad85__Ubuntu_DAILY_BUILD-precise-12_04_3-LTS-amd64-server-20131205-en-us-30GB) + required: true + default: null + role_size: + description: + - azure role size for the new virtual machine (e.g., Small, ExtraLarge, A6) + required: false + default: Small + endpoints: + description: + - a comma-separated list of TCP ports to expose on the virtual machine (e.g., "22,80") + required: false + default: 22 + user: + description: + - the unix username for the new virtual machine. + required: false + default: null + password: + description: + - the unix password for the new virtual machine. + required: false + default: null + ssh_cert_path: + description: + - path to an X509 certificate containing the public ssh key to install in the virtual machine. See http://www.windowsazure.com/en-us/manage/linux/tutorials/intro-to-linux/ for more details. + - if this option is specified, password-based ssh authentication will be disabled. + required: false + default: null + hostname: + description: + - hostname to write /etc/hostname. Defaults to .cloudapp.net. + required: false + default: null + wait: + description: + - wait for the instance to be in state 'running' before returning + required: false + default: "no" + choices: [ "yes", "no" ] + aliases: [] + wait_timeout: + description: + - how long before wait gives up, in seconds + default: 300 + aliases: [] + state: + version_added: "1.3" + description: + - create or terminate instances + required: false + default: 'present' + aliases: [] + +requirements: [ "azure" ] +author: John Whitbeck +''' + +EXAMPLES = ''' +# Note: None of these examples set subscription_id or management_cert_path +# It is assumed that their matching environment variables are set. + +# Provision virtual machine example +- local_action: + module: azure + name: my-virtual-machine + role_size: Small + image: b39f27a8b8c64d52b05eac6a62ebad85__Ubuntu_DAILY_BUILD-precise-12_04_3-LTS-amd64-server-20131205-en-us-30GB + location: 'East US' + user: ubuntu + ssh_cert_path: /path/to/azure_x509_cert.pem + storage_account: my-storage-account + wait: yes + +# Terminate virtual machine example +- local_action: + module: azure + name: my-virtual-machine + state: absent +''' + +import os +import sys +import time +import datetime +from urlparse import urlparse + +AZURE_LOCATIONS = ['East Asia', + 'Southeast Asia', + 'North Europe', + 'West Europe', + 'East US', + 'West US'] + +AZURE_ROLE_SIZES = ['Small', 'Medium', 'Large', 'ExtraLarge', 'A5', 'A6', 'A7'] + +try: + import azure + from azure import WindowsAzureError, WindowsAzureMissingResourceError + from azure.servicemanagement import (ServiceManagementService, OSVirtualHardDisk, SSH, PublicKeys, + PublicKey, LinuxConfigurationSet, ConfigurationSetInputEndpoints, + ConfigurationSetInputEndpoint) +except ImportError: + print "failed=True msg='azure required for this module'" + sys.exit(1) + +def get_ssh_certificate_tokens(ssh_cert_path): + """ + Returns the sha1 fingerprint and a base64-encoded PKCS12 version of the certificate. + """ + # This returns a string such as SHA1 Fingerprint=88:60:0B:13:A9:14:47:DA:4E:19:10:7D:34:92:2B:DF:A1:7D:CA:FF + openssl_x509_output = subprocess.check_output(['openssl', 'x509', '-in', ssh_cert_path, '-fingerprint', '-noout']) + fingerprint = openssl_x509_output.strip()[17:].replace(':','') + + pkcs12_process = subprocess.Popen(['openssl', 'pkcs12', '-export', '-in', ssh_cert_path, '-nokeys', '-password', 'pass:'], stdout=subprocess.PIPE) + pkcs12_base64 = subprocess.check_output(['base64'], stdin=pkcs12_process.stdout).strip() + + return (fingerprint, pkcs12_base64) + + +def create_virtual_machine(module, azure): + """ + Create new virtual machine + + module : AnsibleModule object + azure: authenticated azure ServiceManagementService object + + Returns: + True if a new virtual machine was created, false otherwise + """ + + + name = module.params.get('name') + hostname = module.params.get('hostname') or name + ".cloudapp.net" + endpoints = module.params.get('endpoints').split(',') + ssh_cert_path = module.params.get('ssh_cert_path') + user = module.params.get('user') + password = module.params.get('password') + location = module.params.get('location') + role_size = module.params.get('role_size') + storage_account = module.params.get('storage_account') + image = module.params.get('image') + wait = module.params.get('wait') + wait_timeout = int(module.params.get('wait_timeout')) + + # Check if a deployment with the same name already exists + deployment = None + try: + deployment = azure.get_deployment_by_name(service_name=name, deployment_name=name) + except WindowsAzureMissingResourceError as e: + pass # no such deployment + except WindowsAzureError as e: + module.fail_json(msg = str(e)) + + if deployment: + changed = False + else: + changed = True + + # Create cloud service if necessary + try: + existing_service_names = [service.service_name for service in azure.list_hosted_services()] + if not name in existing_service_names: + azure.create_hosted_service(service_name=name, label=name, location=location) + except WindowsAzureError as e: + module.fail_json(msg = str(e)) + + # Create linux configuration + disable_ssh_password_authentication = not password + linux_config = LinuxConfigurationSet(hostname, user, password, disable_ssh_password_authentication) + + # Add ssh certificates if specified + if ssh_cert_path: + fingerprint, pkcs12_base64 = get_ssh_certificate_tokens(ssh_cert_path) + # Add certificate to cloud service + azure.add_service_certificate(name, pkcs12_base64, 'pfx', '') + # Create ssh config + ssh_config = SSH() + ssh_config.public_keys = PublicKeys() + authorized_keys_path = u'/home/%s/.ssh/authorized_keys' % user + ssh_config.public_keys.public_keys.append(PublicKey(path=authorized_keys_path, fingerprint=fingerprint)) + # Append ssh config to linux machine config + linux_config.ssh = ssh_config + + # Create network configuration + network_config = ConfigurationSetInputEndpoints() + network_config.configuration_set_type = 'NetworkConfiguration' + network_config.subnet_names = [] + for port in endpoints: + network_config.input_endpoints.append(ConfigurationSetInputEndpoint(name='TCP-%s' % port, + protocol='TCP', + port=port, + local_port=port)) + + # First determine where to store disk + today = datetime.date.today().strftime('%Y-%m-%d') + disk_prefix = u'%s-%s' % (name, name) + media_link = u'http://%s.blob.core.windows.net/vhds/%s-%s.vhd' % (storage_account, disk_prefix, today) + # Create system hard disk + os_hd = OSVirtualHardDisk(image, media_link) + + # Spin up virtual machine + try: + azure.create_virtual_machine_deployment(service_name=name, + deployment_name=name, + deployment_slot='production', + label=name, + role_name=name, + system_config=linux_config, + network_config=network_config, + os_virtual_hard_disk=os_hd, + role_size=role_size) + except WindowsAzureError as e: + module.fail_json(msg = str(e)) + + # wait here until the deployment is up + deployment = None + wait_timeout = time.time() + wait_timeout + while wait_timeout > time.time() and not deployment: + try: + deployment = azure.get_deployment_by_name(service_name=name, deployment_name=name) + except WindowsAzureMissingResourceError as e: + pass # deployment still not available + except WindowsAzureError as e: + # got a bad response from azure, wait a second and then try again + time.sleep(1) + continue + if deployment: + break + else: + time.sleep(5) + + return changed + + +def terminate_virtual_machine(module, azure): + """ + Terminates a virtual machine + + module : AnsibleModule object + azure: authenticated azure ServiceManagementService object + + Not yet supported: handle deletion of attached data disks. + + Returns: + True if a new virtual machine was deleted, false otherwise + """ + + # Whether to wait for termination to complete before returning + wait = module.params.get('wait') + wait_timeout = int(module.params.get('wait_timeout')) + name = module.params.get('name') + delete_empty_services = module.params.get('delete_empty_services') + + changed = False + + deployment = None + try: + deployment = azure.get_deployment_by_name(service_name=name, deployment_name=name) + except WindowsAzureMissingResourceError as e: + pass # no such deployment + except WindowsAzureError as e: + module.fail_json(msg = str(e)) + + # Delete deployment + if deployment: + changed = True + try: + # TODO: Also find a way to delete old hard drives + azure.delete_deployment(service_name=name,deployment_name=name) + wait_timeout = time.time() + wait_timeout + while wait_timeout > time.time() and deployment: + try: + deployment = azure.get_deployment_by_name(service_name=name, deployment_name=name) + except WindowsAzureMissingResourceError as e: + break # successfully deleted + except WindowsAzureError as e: + # Azure api error, wait a second and retry + time.sleep(1) + continue + time.sleep(5) + # Now that the vm is deleted, remove the cloud service + azure.delete_hosted_service(service_name=name) + except WindowsAzureError as e: + module.fail_json(msg = str(e)) + + return changed + +def get_azure_creds(module): + # Check modul args for credentials, then check environment vars + subscription_id = module.params.get('subscription_id') + management_cert_path = module.params.get('management_cert_path') + + if not subscription_id: + subscription_id = os.environ['AZURE_SUBSCRIPTION_ID'] + management_cert_path = os.environ['AZURE_MANAGEMENT_CERT_PATH'] + + return subscription_id, management_cert_path + +def main(): + module = AnsibleModule( + argument_spec = dict( + ssh_cert_path = dict(), + name = dict(), + hostname = dict(), + location = dict(choices=AZURE_LOCATIONS), + role_size = dict(choices=AZURE_ROLE_SIZES), + subscription_id = dict(no_log=True), + storage_account = dict(), + management_cert_path = dict(), + endpoints = dict(default='22'), + user = dict(), + password = dict(), + image = dict(), + state = dict(default='present'), + wait = dict(type='bool', default=False), + wait_timeout = dict(default=300) + ) + ) + + # create azure ServiceManagementService object + subscription_id, management_cert_path = get_azure_creds(module) + azure = ServiceManagementService(subscription_id, management_cert_path) + + if module.params.get('state') == 'absent': + changed = terminate_virtual_machine(module, azure) + + + elif module.params.get('state') == 'present': + # Changed is always set to true when provisioning new instances + if not module.params.get('name'): + module.fail_json(msg='name parameter is required for new instance') + if not module.params.get('image'): + module.fail_json(msg='image parameter is required for new instance') + if not module.params.get('user'): + module.fail_json(msg='user parameter is required for new instance') + if not module.params.get('location'): + module.fail_json(msg='location parameter is required for new instance') + if not module.params.get('storage_account'): + module.fail_json(msg='storage_account parameter is required for new instance') + changed = create_virtual_machine(module, azure) + + + module.exit_json(changed=changed) + +# import module snippets +from ansible.module_utils.basic import * + +main() diff --git a/plugins/inventory/windows_azure.ini b/plugins/inventory/windows_azure.ini new file mode 100644 index 00000000000..133a5e5ff63 --- /dev/null +++ b/plugins/inventory/windows_azure.ini @@ -0,0 +1,25 @@ +# Ansible Windows Azure external inventory script settings +# + +[azure] + +# The module needs your Windows Azure subscription ID and Management certificate path. +# These may also be specified on the command line via --subscription-id and --cert-path +# or via the environment variables AZURE_SUBSCRIPTION_ID and AZURE_CERT_PATH +# +#subscription_id = aaaaaaaa-1234-1234-1234-aaaaaaaaaaaa +#cert_path = /path/to/cert.pem + + +# API calls to Windows Azure may be slow. For this reason, we cache the results +# of an API call. Set this to the path you want cache files to be written to. +# One file will be written to this directory: +# - ansible-azure.cache +# +cache_path = /tmp + + +# The number of seconds a cache file is considered valid. After this many +# seconds, a new API call will be made, and the cache file will be updated. +# +cache_max_age = 300 diff --git a/plugins/inventory/windows_azure.py b/plugins/inventory/windows_azure.py new file mode 100755 index 00000000000..594e69b5c50 --- /dev/null +++ b/plugins/inventory/windows_azure.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python + +''' +Windows Azure external inventory script +======================================= + +Generates inventory that Ansible can understand by making API request to +Windows Azure using the azure python library. + +NOTE: This script assumes Ansible is being executed where azure is already +installed. + + pip install azure + +Adapted from the ansible Linode plugin by Dan Slimmon. +''' + +# (c) 2013, John Whitbeck +# +# 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 . + +###################################################################### + +# Standard imports +import re +import sys +import argparse +import os +from urlparse import urlparse +from time import time +try: + import json +except ImportError: + import simplejson as json + +try: + import azure + from azure import WindowsAzureError + from azure.servicemanagement import ServiceManagementService +except ImportError as e: + print "failed=True msg='`azure` library required for this script'" + sys.exit(1) + + +# Imports for ansible +import ConfigParser + +class AzureInventory(object): + def __init__(self): + """Main execution path.""" + # Inventory grouped by display group + self.inventory = {} + # Index of deployment name -> host + self.index = {} + + # Read settings and parse CLI arguments + self.read_settings() + self.read_environment() + self.parse_cli_args() + + # Initialize Azure ServiceManagementService + self.sms = ServiceManagementService(self.subscription_id, self.cert_path) + + # Cache + if self.args.refresh_cache: + self.do_api_calls_update_cache() + elif not self.is_cache_valid(): + self.do_api_calls_update_cache() + + # Data to print + if self.args.list: + # Display list of nodes for inventory + if len(self.inventory) == 0: + data_to_print = self.get_inventory_from_cache() + else: + data_to_print = self.json_format_dict(self.inventory, True) + + print data_to_print + + def is_cache_valid(self): + """Determines if the cache file has expired, or if it is still valid.""" + if os.path.isfile(self.cache_path_cache): + mod_time = os.path.getmtime(self.cache_path_cache) + current_time = time() + if (mod_time + self.cache_max_age) > current_time: + if os.path.isfile(self.cache_path_index): + return True + return False + + def read_settings(self): + """Reads the settings from the .ini file.""" + config = ConfigParser.SafeConfigParser() + config.read(os.path.dirname(os.path.realpath(__file__)) + '/windows_azure.ini') + + # Credentials related + if config.has_option('azure', 'subscription_id'): + self.subscription_id = config.get('azure', 'subscription_id') + if config.has_option('azure', 'cert_path'): + self.cert_path = config.get('azure', 'cert_path') + + # Cache related + if config.has_option('azure', 'cache_path'): + cache_path = config.get('azure', 'cache_path') + self.cache_path_cache = cache_path + "/ansible-azure.cache" + self.cache_path_index = cache_path + "/ansible-azure.index" + if config.has_option('azure', 'cache_max_age'): + self.cache_max_age = config.getint('azure', 'cache_max_age') + + def read_environment(self): + ''' Reads the settings from environment variables ''' + # Credentials + if os.getenv("AZURE_SUBSCRIPTION_ID"): self.subscription_id = os.getenv("AZURE_SUBSCRIPTION_ID") + if os.getenv("AZURE_CERT_PATH"): self.cert_path = os.getenv("AZURE_CERT_PATH") + + + def parse_cli_args(self): + """Command line argument processing""" + parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on Azure') + parser.add_argument('--list', action='store_true', default=True, + help='List nodes (default: True)') + parser.add_argument('--refresh-cache', action='store_true', default=False, + help='Force refresh of cache by making API requests to Azure (default: False - use cache files)') + self.args = parser.parse_args() + + def do_api_calls_update_cache(self): + """Do API calls, and save data in cache files.""" + self.add_cloud_services() + self.write_to_cache(self.inventory, self.cache_path_cache) + self.write_to_cache(self.index, self.cache_path_index) + + def add_cloud_services(self): + """Makes an Azure API call to get the list of cloud services.""" + try: + for cloud_service in self.sms.list_hosted_services(): + self.add_deployments(cloud_service) + except WindowsAzureError as e: + print "Looks like Azure's API is down:" + print + print e + sys.exit(1) + + def add_deployments(self, cloud_service): + """Makes an Azure API call to get the list of virtual machines associated with a cloud service""" + try: + for deployment in self.sms.get_hosted_service_properties(cloud_service.service_name,embed_detail=True).deployments.deployments: + if deployment.deployment_slot == "Production": + self.add_deployment(cloud_service, deployment) + except WindowsAzureError as e: + print "Looks like Azure's API is down:" + print + print e + sys.exit(1) + + def add_deployment(self, cloud_service, deployment): + """Adds a deployment to the inventory and index""" + + dest = urlparse(deployment.url).hostname + + # Add to index + self.index[dest] = deployment.name + + # List of all azure deployments + self.push(self.inventory, "azure", dest) + + # Inventory: Group by service name + self.push(self.inventory, self.to_safe(cloud_service.service_name), dest) + + # Inventory: Group by region + self.push(self.inventory, self.to_safe(cloud_service.hosted_service_properties.location), dest) + + def push(self, my_dict, key, element): + """Pushed an element onto an array that may not have been defined in the dict.""" + if key in my_dict: + my_dict[key].append(element); + else: + my_dict[key] = [element] + + def get_inventory_from_cache(self): + """Reads the inventory from the cache file and returns it as a JSON object.""" + cache = open(self.cache_path_cache, 'r') + json_inventory = cache.read() + return json_inventory + + def load_index_from_cache(self): + """Reads the index from the cache file and sets self.index.""" + cache = open(self.cache_path_index, 'r') + json_index = cache.read() + self.index = json.loads(json_index) + + def write_to_cache(self, data, filename): + """Writes data in JSON format to a file.""" + json_data = self.json_format_dict(data, True) + cache = open(filename, 'w') + cache.write(json_data) + cache.close() + + def to_safe(self, word): + """Escapes any characters that would be invalid in an ansible group name.""" + return re.sub("[^A-Za-z0-9\-]", "_", word) + + def json_format_dict(self, data, pretty=False): + """Converts a dict to a JSON object and dumps it as a formatted string.""" + if pretty: + return json.dumps(data, sort_keys=True, indent=2) + else: + return json.dumps(data) + + +AzureInventory()