Quellcode durchsuchen

Merged in aznashwan/coriolis/arm-testing (pull request #2)

Arm testing
Alessandro Pilotti vor 9 Jahren
Ursprung
Commit
a0e6760ae4

+ 1 - 0
coriolis/constants.py

@@ -30,6 +30,7 @@ HYPERVISOR_QEMU = "qemu"
 HYPERVISOR_KVM = "kvm"
 HYPERVISOR_XENSERVER = "xenserver"
 
+PLATFORM_AZURE_RM = "azure"
 PLATFORM_OPENSTACK = "openstack"
 PLATFORM_VMWARE_VSPHERE = "vmware_vsphere"
 

+ 1011 - 0
coriolis/providers/azure/__init__.py

@@ -0,0 +1,1011 @@
+# Copyright 2016 Cloudbase Solutions Srl
+# All Rights Reserved.
+
+""" This module defines the Azure Resource Manager Importer and Exporter. """
+
+import collections
+import contextlib
+import math
+import os
+import os.path as path
+import re
+import tempfile
+import time
+import traceback
+
+import paramiko
+import requests
+from azure.mgmt import compute, network, storage
+from azure.mgmt.resource import resources
+from azure.storage import blob
+from msrestazure import azure_active_directory, azure_exceptions
+from oslo_config import cfg
+from oslo_log import log as logging
+from oslo_utils import units
+
+from coriolis import constants
+from coriolis import exception
+from coriolis.osmorphing import manager as osmorpher
+from coriolis.providers.base import BaseImportProvider
+from coriolis.providers.azure import utils as azutils
+from coriolis.providers.azure import exceptions as azexceptions
+from coriolis import schemas
+from coriolis import utils
+
+
+OPTIONS = [
+    # TODO: AzureStack soon: default for "auth_url", used in client creation.
+    cfg.StrOpt("default_auth_url",
+               default="https://management.core.windows.net/",
+               help="The API endpoint to authenticate and perform operations"
+                    "against."),
+    cfg.StrOpt("migr_location",
+               default="westus",
+               help="The default Azure location to migrate to "
+                    "when no location is specified through the "
+                    "target_environment."),
+    cfg.StrOpt("migr_subnet_name",
+               default="coriolis-worknet",
+               help="Name of the subnet to be used by worker instances."),
+    cfg.StrOpt("migr_container_name",
+               default="coriolis",
+               help="Name of the azure storage container which"
+                    "will support the migration."),
+    cfg.StrOpt("default_migration_hostname",
+               default="migratedinst",
+               help="Default hostname to be used in case the hostname of the "
+                    "instance being migrated does not conform with Azure's "
+                    "standards (no special characters of maximum length 15)."),
+    cfg.DictOpt("worker_volume_count_to_size_map",
+                default=collections.OrderedDict([
+                    (2, compute.models.VirtualMachineSizeTypes.standard_d1),
+                    (4, compute.models.VirtualMachineSizeTypes.standard_d2),
+                    (8, compute.models.VirtualMachineSizeTypes.standard_d3),
+                    (16, compute.models.VirtualMachineSizeTypes.standard_d4),
+                    (32, compute.models.VirtualMachineSizeTypes.standard_d5_v2)
+                ]),
+                help="Mapping between the number of volumes supported by "
+                     "an Azure instance size and its name (with correct "
+                     "capitalization and underscores) as show here: "
+                     "https://azure.microsoft.com/en-us/documentation/"
+                     "articles/virtual-machines-windows-sizes/"),
+    cfg.DictOpt("migr_image_sku_map",
+                default={
+                    constants.OS_TYPE_LINUX: "14.04.3-LTS",
+                    constants.OS_TYPE_WINDOWS: "2012-R2-Datacenter"
+                },
+                help="Mapping of image SKUs to be used for migration workers "
+                     "with respect to the type of OS being migrated.")
+]
+
+CONF = cfg.CONF
+CONF.register_opts(OPTIONS, "azure_migration_provider")
+
+LOG = logging.getLogger(__name__)
+
+MIGRATION_RESGROUP_NAME_FORMAT = "coriolis-migration-%s"
+MIGRATION_WORKER_NAME_FORMAT = "coriolis-worker-%s"
+AZURE_DISK_NAME_FORMAT = "%s.vhd"
+MIGRATION_WORKER_NIC_NAME_FORMAT = "coriolis-worker-nic-%s"
+MIGRATION_WORKER_PIP_NAME_FORMAT = "coriolis-worker-pip-%s"
+MIGRATION_NETWORK_NAME_FORMAT = "coriolis-migrnet-%s"
+
+BLOB_PATH_FORMAT = "https://%s.blob.core.windows.net/%s/%s"
+
+SSH_PUBKEY_FORMAT = "ssh-rsa %s tmp@migration"
+SSH_PUBKEY_FILEPATH_FORMAT = "/home/%s/.ssh/authorized_keys"
+
+PROVIDERS_NAME_MAP = {
+    x: "Microsoft." + x.capitalize()
+    for x in ["compute", "network"]
+}
+
+WORKER_VM_IMAGE_VERSION = "latest"
+
+WORKER_IMAGES_MAP = {
+    constants.OS_TYPE_LINUX: {
+        "version": WORKER_VM_IMAGE_VERSION,
+        "publisher": "canonical",
+        "offer": "UbuntuServer"
+    },
+    constants.OS_TYPE_WINDOWS: {
+        "version": WORKER_VM_IMAGE_VERSION,
+        "publisher": "MicrosoftWindowsServer",
+        "offer": "WindowsServer"
+    },
+}
+
+AZURE_OSTYPES_MAP = {
+    "linux":   compute.models.OperatingSystemTypes.linux,
+    "windows": compute.models.OperatingSystemTypes.windows
+}
+
+WINRM_EXTENSION_FILES_BASE_URI = (
+    # TODO: not fetch off github
+    "https://raw.githubusercontent.com/cloudbase/coriolis-resources/master/azure/")
+
+WINRM_EXTENSION_FILE_URIS = [
+    WINRM_EXTENSION_FILES_BASE_URI + f
+    for f in ["ConfigureWinRM.ps1", "makecert.exe", "winrmconf.cmd"]
+]
+
+WORKER_USERNAME = "coriolis"
+
+# NOTE: connection_info
+CREDENTIALS_CATEGORIES = ["user_credentials", "service_principal_credentials"]
+USER_CREDENTIALS_FIELDS = ["username", "password"]
+SP_CREDENTIALS_FIELDS = ["client_id", "client_secret", "tenant_id"]
+
+# An AzureStorageBlob is identified by its unique name and URI on Azure.
+AzureStorageBlob = collections.namedtuple(
+    "AzureStorageBlob", "name uri")
+
+# An AzureWorkerOSProfile is identified by the compute.models.OSProfile, the
+# authentication token to the machine (ssh key or cert thubprint), the port
+# awaiting the respective connection, as well as a list of
+# compute.models.VirtualMachineExtension's required by the worker.
+AzureWorkerOSProfile = collections.namedtuple(
+    "AzureWorkerOSProfile", "profile token port extensions")
+
+# An AzureWorkerInstance is identified by its unique name, ip address, port,
+# username, password, authentication token and its list of datadisks.
+AzureWorkerInstance = collections.namedtuple(
+    "AzureWorkerInstance", "name ip port username password pkey datadisks")
+
+
+class ImportProvider(BaseImportProvider):
+    """ Provides import capabilities. """
+
+    connection_info_schema = schemas.get_schema(
+        __name__, schemas.PROVIDER_CONNECTION_INFO_SCHEMA_NAME)
+
+    target_environment_schema = schemas.get_schema(
+        __name__, schemas.PROVIDER_TARGET_ENVIRONMENT_SCHEMA_NAME)
+
+    def validate_connection_info(self, connection_info):
+        """ Validates the provided connection information. """
+        LOG.info("Validating connection info: %s", connection_info)
+
+        if not super(ImportProvider, self).validate_connection_info(
+                connection_info):
+            return False
+
+        # NOTE: considering we cannot check the validity of the credentials per
+        # se if a secret href is provided here, we simply return on the spot:
+        if "secret_ref" in connection_info:
+            return True
+
+        try:
+            # NOTE: attempt to register to a provider to ensure credentials
+            # are indeed actually valid.
+            resc = self._get_resource_client(connection_info)
+            utils.retry_on_error()(azutils.checked(resc.providers.register))(
+                PROVIDERS_NAME_MAP["compute"])
+        except (KeyError, azure_exceptions.CloudError,
+                azexceptions.AzureOperationException) as ex:
+
+            LOG.info(
+                "Invalid or incomplete Azure credentials provided: %s\n%s",
+                connection_info, ex)
+            return False
+        else:
+            return True
+
+    def _get_cloud_credentials(self, connection_info):
+        """ returns the msrestazure.azure_active_directory.Credentials
+        implementation for the given connection_info. Should both user/pass and
+        service principal details be provided, the user/pass auth flow is
+        preffered.
+        """
+        user_creds, sp_creds = [connection_info.get(x, {}) for x in
+                                CREDENTIALS_CATEGORIES]
+
+        if user_creds and all([x in user_creds for x in
+                               USER_CREDENTIALS_FIELDS]):
+            return azure_active_directory.UserPassCredentials(
+                user_creds["username"],
+                user_creds["password"]
+            )
+
+        if sp_creds and all([x in sp_creds for x in
+                             SP_CREDENTIALS_FIELDS]):
+            return azure_active_directory.ServicePrincipalCredentials(
+                client_id=sp_creds["client_id"],
+                secret=sp_creds["client_secred"],
+                tenent=sp_creds["tenant_id"]
+            )
+
+        # NOTE: this ending raise is redundant considering the schema-based
+        # validation; but allows for another layer of validation considering
+        # a possible discrepancy between the schema and the code...
+        raise azexceptions.AzureOperationException(
+            msg="Either 'user_credentials' or 'service_principal_credentials' "
+            "must be specified in 'connection_info'.",
+            code=-1)
+
+    def _get_compute_client(self, connection_info):
+        """ Returns an azure.mgmt.compute.ComputeManagementClient.
+        """
+        return compute.ComputeManagementClient(
+            self._get_cloud_credentials(connection_info),
+            connection_info["subscription_id"])
+
+    def _get_network_client(self, connection_info):
+        """ Returns an azure.mgmt.network.NetworkResourceProviderClient. """
+        return network.NetworkManagementClient(
+            self._get_cloud_credentials(connection_info),
+            connection_info["subscription_id"])
+
+    def _get_storage_client(self, connection_info):
+        """ Returns an azure.mgmt.storage.StorageManagementClient. """
+        return storage.StorageManagementClient(
+            self._get_cloud_credentials(connection_info),
+            connection_info["subscription_id"])
+
+    def _get_resource_client(self, connection_info):
+        """ Returns an azure.mgmt.resource.ResourceManagementClient. """
+        return resources.ResourceManagementClient(
+            self._get_cloud_credentials(connection_info),
+            connection_info["subscription_id"])
+
+    def _get_page_blob_client(self, target_environment):
+        """ Returns an azure.storage.blob.PageBlobService client. """
+        return blob.PageBlobService(
+            account_name=target_environment["storage"]["account"],
+            account_key=target_environment["storage"]["key"]
+        )
+
+    def _get_block_blob_client(self, target_environment):
+        """ Returns an azure.storage.blob.PageBlobService client. """
+        return blob.BlockBlobService(
+            account_name=target_environment["storage"]["account"],
+            account_key=target_environment["storage"]["key"]
+        )
+
+    def _delete_recovery_disk(self, target_environment, vm_name):
+        """ Removes the recovery disk block blob for the given
+        instance name.
+        """
+        blobd = self._get_block_blob_client(target_environment)
+        cont_name = CONF.azure_migration_provider.migr_container_name
+
+        blob_names = [b.name
+                      for b in blobd.list_blobs(cont_name)
+                      if re.match(r"%s\..*\.status" % vm_name, b.name)]
+        if len(blob_names) > 1:
+            self._event_manager.progress_update(
+                "VM '%s' has more than one recovery disk. "
+                "Skipped deleting any to avoid adverse loss")
+
+        if len(blob_names) == 1:
+            blobd.delete_blob(cont_name, blob_names[0])
+
+    @utils.retry_on_error()
+    def _upload_disk(self, target_environment, disk_path, upload_name):
+        """ Uploads the disk from the provided path to an Azure blob
+        and returns the corresponding AzureStorageBlob.
+        """
+        self._event_manager.progress_update(
+            "Uploading disk from '%s' as '%s'" % (disk_path, upload_name))
+
+        def progressf(curr, total):
+            LOG.debug("Uploading '%s': %d/%d.", upload_name, curr, total)
+
+        blobd = self._get_page_blob_client(target_environment)
+
+        stor_name = target_environment["storage"]["account"]
+        cont_name = CONF.azure_migration_provider.migr_container_name
+        disk_uri = BLOB_PATH_FORMAT % (stor_name, cont_name, upload_name)
+
+        blobd.create_blob_from_path(
+            cont_name, upload_name, disk_path, progress_callback=progressf)
+
+        return AzureStorageBlob(
+            name=upload_name,
+            uri=disk_uri
+        )
+
+    @utils.retry_on_error()
+    def _create_migration_network(self, connection_info, target_environment,
+                                  migration_id):
+        """ Creates the virtual network to be used for the migration and
+        returns the response from its creation operation.
+        """
+        self._event_manager.progress_update(
+            "Creating migration network")
+
+        awaited = azutils.awaited(timeout=150)
+        netc = self._get_network_client(connection_info)
+        resgroup = MIGRATION_RESGROUP_NAME_FORMAT % migration_id
+        vn_name = MIGRATION_NETWORK_NAME_FORMAT % migration_id
+
+        awaited(netc.virtual_networks.create_or_update)(
+            resgroup,
+            vn_name,
+            network.models.VirtualNetwork(
+                location=target_environment.get(
+                    "location",
+                    CONF.azure_migration_provider.migr_location),
+                address_space=network.models.AddressSpace(
+                    # NOTE: can safely be completely arbitrary:
+                    address_prefixes=["10.0.0.0/16"]
+                ),
+                subnets=[
+                    network.models.Subnet(
+                        name=CONF.azure_migration_provider.migr_subnet_name,
+                        # NOTE: can safely be completely arbitrary:
+                        address_prefix='10.0.0.0/24'
+                    )
+                ]
+            ),
+        )
+
+        return azutils.checked(netc.virtual_networks.get)(resgroup, vn_name)
+
+    @utils.retry_on_error(
+        terminal_exceptions=[azexceptions.FatalAzureOperationException])
+    def _wait_for_vm(self, connection_info, resgroup, vm_name, period=30):
+        """ Blocks until the given VM has been started. """
+        vmclient = self._get_compute_client(connection_info).virtual_machines
+        state = azutils.checked(vmclient.get)(resgroup, vm_name)
+
+        while state.provisioning_state != "Succeeded":
+            if state.provisioning_state not in ("Creating", "Updating"):
+                raise azexceptions.FatalAzureOperationException(
+                    code=-1,
+                    msg="Awaited VM '%s' has reached an invalid state: %s" %
+                    (vm_name, state.provisioning_state)
+                )
+
+            time.sleep(period)
+            state = azutils.checked(vmclient.get)(resgroup, vm_name)
+
+    def _get_worker_osprofile(self, os_type, location, worker_name):
+        """ Returns the AzureWorkerOSProfile associated to the worker instance
+        on Azure. For Linux workers, the returned authentication token is a
+        paramiko.RSAKey the machine has been configured with.
+        For windows workers, the configured password is returned.
+        """
+        if os_type == constants.OS_TYPE_LINUX:
+            return self._get_linux_worker_osprofile(worker_name)
+        elif os_type == constants.OS_TYPE_WINDOWS:
+            return self._get_windows_worker_osprofile(
+                location, worker_name)
+
+        raise azexceptions.FatalAzureOperationException(
+            code=-1,
+            msg="Unsupported migration OS type '%s'." % os_type
+        )
+
+    def _get_linux_worker_osprofile(self, worker_name):
+        """ Returns the AzureWorkerOSProfile afferent to a Linux worker. """
+        LOG.info("Linux chosen as worker '%s' OS.", worker_name)
+
+        key = paramiko.RSAKey.generate(2048)
+
+        os_profile = compute.models.OSProfile(
+            admin_username=WORKER_USERNAME,
+            # NOTE: intentional lack of 'admin_password' provided so
+            # as to enable passwordless sudo on the target machine.
+            computer_name=worker_name,
+            linux_configuration=compute.models.LinuxConfiguration(
+                disable_password_authentication=True,
+                ssh=compute.models.SshConfiguration(
+                    public_keys=[compute.models.SshPublicKey(
+                        key_data=SSH_PUBKEY_FORMAT % key.get_base64(),
+                        path=SSH_PUBKEY_FILEPATH_FORMAT % WORKER_USERNAME
+                    )],
+                ),
+            )
+        )
+
+        return AzureWorkerOSProfile(
+            profile=os_profile,
+            token=key,
+            port=22,  # NOTE: default port used on Azure-provided images.
+            extensions=[]
+        )
+
+    def _get_windows_worker_osprofile(self, location, worker_name):
+        """ Returns the AzureWorkerOSProfile afferent to a Windows worker. """
+        LOG.info("Windows was chosen as worker '%s' OS.", worker_name)
+
+        password = azutils.get_random_password()
+        os_profile = compute.models.OSProfile(
+            computer_name=azutils.normalize_hostname(worker_name),
+            admin_username=WORKER_USERNAME,
+            admin_password=password,
+            windows_configuration=compute.models.WindowsConfiguration(
+                provision_vm_agent=True,
+                enable_automatic_updates=False,
+            )
+        )
+
+        winrm_extension = compute.models.VirtualMachineExtension(
+            location,
+            publisher=PROVIDERS_NAME_MAP["compute"],
+            virtual_machine_extension_type="CustomScriptExtension",
+            type_handler_version="1.4",
+            settings=dict(
+                fileUris=WINRM_EXTENSION_FILE_URIS,
+                commandToExecute="powershell -ExecutionPolicy Unrestricted "
+                                 "-file ConfigureWinRM.ps1 "
+                                 "*.%s.cloudapp.azure.com" % location
+            ),
+        )
+
+        return AzureWorkerOSProfile(
+            profile=os_profile,
+            token=password,
+            port=5986,  # NOTE: default port opened via the extension
+            extensions=[winrm_extension]
+        )
+
+    @utils.retry_on_error()
+    def _create_nic(self, connection_info, resgroup, nic_name,
+                    location, ip_configs):
+        """ Creates a network interface with the specified params.
+
+        NOTE: Azure does unfortunately not allow for the setting of MAC
+        addresses. A NIC's MAC is determined when the VM the NIC is attached
+        to is booted.
+        """
+
+        awaited = azutils.awaited(timeout=200)
+        net_client = self._get_network_client(connection_info)
+
+        nic = network.models.NetworkInterface(
+            location=location,
+            ip_configurations=ip_configs
+        )
+
+        awaited(net_client.network_interfaces.create_or_update)(
+            resgroup,
+            nic_name,
+            nic
+        )
+
+        return azutils.checked(net_client.network_interfaces.get)(
+            resgroup, nic_name)
+
+    @utils.retry_on_error()
+    def _create_public_ip(self, connection_info, resgroup, ip_name, location):
+        awaited = azutils.awaited(timeout=90)
+        net_client = self._get_network_client(connection_info)
+
+        awaited(net_client.public_ip_addresses.create_or_update)(
+            resgroup,
+            ip_name,
+            network.models.PublicIPAddress(
+                location=location,
+                public_ip_allocation_method=network.models.IPAllocationMethod.dynamic,
+            ),
+        )
+
+        return azutils.checked(net_client.public_ip_addresses.get)(
+            resgroup, ip_name)
+
+    def _convert_to_vhd(self, disk_path):
+        """ Converts the disk file given by its path to a fixed VHD and returns
+        the path of the resulting disk.
+
+        Does NOT check if it already is a VHD.
+        """
+        newpath = "%s.%s" % (
+            path.splitext(disk_path)[0],
+            constants.DISK_FORMAT_VHD
+        )
+
+        try:
+            utils.convert_disk_format(
+                disk_path, newpath,
+                constants.DISK_FORMAT_VHD,
+                preallocated=True
+            )
+        except Exception as ex:
+            raise azexceptions.FatalAzureOperationException(
+                code=-1,
+                msg="Unable to convert disk '%s' to fixed VHD." % disk_path
+            ) from ex
+
+        return newpath
+
+    def _migrate_disk(self, target_environment, lun,
+                      disk_path, upload_name):
+        """ Moves the given disk file to Azure storage as a page blob. """
+        disk_file_info = utils.get_disk_info(disk_path)
+
+        upload_path = disk_path
+
+        # convert disk if necessary:
+        if disk_file_info["format"] != constants.DISK_FORMAT_VHD:
+            self._event_manager.progress_update(
+                "Converting disk '%s' from '%s' to 'vhd'" %
+                (upload_name, disk_file_info["format"]))
+
+            upload_path = self._convert_to_vhd(disk_path)
+            os.remove(disk_path)
+
+        try:
+            blb = self._upload_disk(
+                target_environment, upload_path, upload_name)
+        except Exception as ex:
+            # NOTE: _upload_disk is set to retry, so it's game over here:
+            raise azexceptions.FatalAzureOperationException(
+                code=-1,
+                msg="Failed to upload disk '%s' to Azure." % upload_name
+            ) from ex
+        finally:
+            os.remove(upload_path)
+
+        return compute.models.DataDisk(
+            lun=lun,
+            name=blb.name,
+            caching=compute.models.CachingTypes.none,
+            vhd=compute.models.VirtualHardDisk(uri=blb.uri),
+            # NOTE: we must force Azure to resize the disk itself to ensure the
+            # 1MB internal size alignment without which the VM will not boot.
+            # Maximum granularity for specifying disk size on Azure is 1GB.
+            disk_size_gb=math.ceil(disk_file_info["virtual-size"] / units.Gi) + 2,
+            create_option=compute.models.DiskCreateOptionTypes.attach,
+        )
+
+    @utils.retry_on_error()
+    def _get_subnet(self, connection_info, resgroup, vn_name, sub_name):
+        """ Fetches information for the specified subnet. """
+        net_client = self._get_network_client(connection_info)
+
+        return azutils.checked(net_client.subnets.get)(
+            resgroup, vn_name, sub_name)
+
+    @utils.retry_on_error()
+    def _get_pub_ip(self, connection_info, resgroup, ip_name):
+        """ Fetches information for the given public IP address. """
+        net_client = self._get_network_client(connection_info)
+
+        return azutils.checked(net_client.public_ip_addresses.get)(
+            resgroup, ip_name)
+
+    def _get_worker_size(self, worker_name, ndisks):
+        """ Returns the required size of the worker needed to handle
+        the migration/transformation of the data disks. """
+        mapping = CONF.azure_migration_provider.worker_volume_count_to_size_map
+        volsmap = collections.OrderedDict(
+            [(n, mapping[n]) for n in sorted(mapping.keys())])
+
+        for maxvols, size in volsmap.items():
+            if ndisks <= maxvols:
+                self._event_manager.progress_update(
+                    "Migration worker size chosen as '%s'" %
+                    str(size).split(".")[-1])
+
+                return size
+
+        raise azexceptions.FatalAzureOperationException(
+            code=-1,
+            msg=("No Azure size suitable for migrating the instance "
+                 "as it has too many volumes (%d).", ndisks)
+        )
+
+    @utils.retry_on_error(
+        terminal_exceptions=[azexceptions.FatalAzureOperationException])
+    def _create_migration_worker(self, connection_info, target_environment,
+                                 export_info, migration_id, datadisks):
+        """ Creates and returns the connection information of the worker which
+        will perform the final operations on the migrating VM.
+        """
+        resgroup = MIGRATION_RESGROUP_NAME_FORMAT % migration_id
+        worker_name = MIGRATION_WORKER_NAME_FORMAT % migration_id
+
+        self._event_manager.progress_update("Creating migration worker")
+
+        awaited = azutils.awaited(timeout=600)
+        location = target_environment.get(
+            "location", CONF.azure_migration_provider.migr_location)
+        location = azutils.normalize_location(location)
+
+        # create the NIC and public IP:
+        self._event_manager.progress_update(
+            "Creating migration worker Public IP")
+
+        ip_name = MIGRATION_WORKER_PIP_NAME_FORMAT % migration_id
+        pub_ip = self._create_public_ip(connection_info, resgroup,
+                                        ip_name, location)
+
+        self._event_manager.progress_update(
+            "Creating migration worker NIC")
+
+        nic_name = MIGRATION_WORKER_NIC_NAME_FORMAT % migration_id
+        nic = self._create_nic(
+            connection_info, resgroup, nic_name, location,
+            ip_configs=[
+                network.models.NetworkInterfaceIPConfiguration(
+                    name=nic_name + "-ipconf",
+                    subnet=self._get_subnet(
+                        connection_info,
+                        resgroup,
+                        MIGRATION_NETWORK_NAME_FORMAT % migration_id,
+                        CONF.azure_migration_provider.migr_subnet_name
+                    ),
+                    private_ip_allocation_method=network.models.IPAllocationMethod.dynamic,
+                    public_ip_address=pub_ip,
+                )
+            ]
+        )
+
+        # create the worker:
+        worker_disk_name = AZURE_DISK_NAME_FORMAT % worker_name
+        computec = self._get_compute_client(connection_info)
+        worker_osprofile = self._get_worker_osprofile(
+            export_info["os_type"], location, worker_name)
+        worker_img = WORKER_IMAGES_MAP[export_info["os_type"]]
+        target_env_sku_map = target_environment.get("migr_image_sku_map", {})
+        conf_sku = CONF.azure_migration_provider.migr_image_sku_map.get(
+            export_info["os_type"])
+        worker_img_sku = target_environment.get(
+            "migr_image_sku",
+            target_env_sku_map.get(export_info["os_type"], conf_sku))
+
+
+        awaited(computec.virtual_machines.create_or_update)(
+            resgroup,
+            worker_name,
+            compute.models.VirtualMachine(
+                location=location,
+                os_profile=worker_osprofile.profile,
+                hardware_profile=compute.models.HardwareProfile(
+                    self._get_worker_size(worker_name, len(datadisks))
+                ),
+                network_profile=compute.models.NetworkProfile(
+                    network_interfaces=[
+                        compute.models.NetworkInterfaceReference(
+                            id=nic.id,
+                        ),
+                    ]
+                ),
+                storage_profile=compute.models.StorageProfile(
+                    os_disk=compute.models.OSDisk(
+                        caching=compute.models.CachingTypes.none,
+                        create_option=compute.models.DiskCreateOptionTypes.from_image,
+                        name=worker_disk_name,
+                        vhd=compute.models.VirtualHardDisk(
+                            BLOB_PATH_FORMAT % (
+                                target_environment["storage"]["account"],
+                                CONF.azure_migration_provider.migr_container_name,
+                                worker_disk_name
+                            )
+                        ),
+                    ),
+                    data_disks=datadisks,
+                    image_reference=compute.models.ImageReference(
+                        publisher=worker_img["publisher"],
+                        offer=worker_img["offer"],
+                        sku=worker_img_sku,
+                        version=worker_img["version"]
+                    )
+                )
+            )
+        )
+
+        self._event_manager.progress_update("Waiting for migration worker to start")
+        self._wait_for_vm(connection_info, resgroup, worker_name)
+        self._event_manager.progress_update("Migration worker has started succesfully")
+
+        # add any neccessary extensions to the Worker:
+        for ext in worker_osprofile.extensions:
+            self._event_manager.progress_update(
+                "Creating Worker VM extensions '%s'" % ext.name)
+
+            awaited(computec.virtual_machine_extensions.create_or_update)(
+                resgroup,
+                worker_name,
+                ext.name,
+                ext
+            )
+
+        # fetch the instance's allocated ip:
+        pub_ip = self._get_pub_ip(connection_info, resgroup, ip_name)
+
+        return AzureWorkerInstance(
+            name=worker_name,
+            ip=pub_ip.ip_address,
+            port=worker_osprofile.port,
+            datadisks=datadisks,
+            username=worker_osprofile.profile.admin_username,
+            password=worker_osprofile.profile.admin_password,
+            pkey=worker_osprofile.token
+        )
+
+    def _get_migration_os_profile(self, os_type, instance_name):
+        computer_name = instance_name
+        if os_type == constants.OS_TYPE_WINDOWS:
+            computer_name = azutils.normalize_hostname(instance_name)
+
+        profile = compute.models.OSProfile(
+            computer_name=computer_name,
+            # NOTE: here we are forced to provide a username and password to
+            # the API. These values will be ignored by the Azure agent as
+            # provisioning is turned off in the agent's configuration in
+            # the osmorphing stage.
+            admin_username="coriolis",
+            admin_password=azutils.get_random_password()
+        )
+
+        if os_type == constants.OS_TYPE_WINDOWS:
+            profile.windows_configuration = (
+                compute.models.WindowsConfiguration(
+                    provision_vm_agent=True,
+                    enable_automatic_updates=False,
+                )
+            )
+
+        return profile
+
+    def import_instance(self, ctxt, connection_info, target_environment,
+                        instance_name, export_info):
+        """ Runs the process of importing the instance to Azure. """
+        self._event_manager.progress_update(
+            "Importing instance '%s'" % instance_name)
+
+        awaited = azutils.awaited(300)
+        location = target_environment.get(
+            "location", CONF.azure_migration_provider.migr_location)
+        location = azutils.normalize_location(location)
+
+        migration_id = azutils.get_unique_id()[:10]
+        resgroup = MIGRATION_RESGROUP_NAME_FORMAT % migration_id
+
+        worker_info = None
+
+        try:
+            # setup storage container:
+            cont_name = CONF.azure_migration_provider.migr_container_name
+            self._event_manager.progress_update(
+                "Creating migration storage container '%s'" % cont_name)
+
+            blobd = self._get_page_blob_client(target_environment)
+            utils.retry_on_error()(blobd.create_container)(
+                cont_name, public_access=blob.PublicAccess.Container)
+
+            # migrate the instance's attached volumes:
+            datadisks = []
+            for lun, disk in enumerate(export_info["devices"]["disks"]):
+                LOG.info("Processing instance disk number %d", (lun + 1))
+
+                disk_name = AZURE_DISK_NAME_FORMAT % (
+                    instance_name + "_" + str(lun))
+                try:
+                    datadisk = self._migrate_disk(
+                        target_environment, lun, disk["path"], disk_name)
+                except:
+                    raise
+                else:
+                    datadisks.append(datadisk)
+
+            # setup migration resource group:
+            self._event_manager.progress_update(
+                "Creating migration resource group '%s'" % resgroup)
+
+            resc = self._get_resource_client(connection_info)
+            utils.retry_on_error()(
+                azutils.checked(resc.resource_groups.create_or_update))(
+                    resgroup,
+                    resources.models.ResourceGroup(location))
+
+            # create the migration network:
+            self._create_migration_network(
+                connection_info, target_environment, migration_id)
+
+            # create the worker:
+            worker_info = self._create_migration_worker(
+                connection_info, target_environment, export_info,
+                migration_id, datadisks)
+
+            # morph the images:
+            self._event_manager.progress_update(
+                "Preparing instance for new environment")
+
+            osmorpher.morph_image(
+                worker_info._asdict(),
+                export_info["os_type"],
+                constants.HYPERVISOR_HYPERV,
+                constants.PLATFORM_AZURE_RM,
+                None,
+                self._event_manager,
+                ignore_devices=["/dev/sdb"]
+            )
+
+            # delete the worker:
+            self._event_manager.progress_update("Deleting migration worker")
+            vmclient = self._get_compute_client(
+                connection_info).virtual_machines
+            utils.retry_on_error()(awaited(vmclient.delete))(
+                resgroup, worker_info.name)
+
+            # setup vm nics:
+            self._event_manager.progress_update(
+                "Setting up instance NICs")
+
+            nics = []
+            for i, nic in enumerate(export_info["devices"].get("nics", [])):
+                nic_name = nic.get("name", "%s-NIC-%d" % (instance_name, i + 1))
+                net_name = nic.get("network_name", None)
+                subnet_name = nic.get("subnet_name", None)
+                add_public_ip = False
+
+                if not net_name:
+                    net = target_environment.get("destination_network", {})
+                    net_name = net.get("name", None)
+
+                    if not net or not net_name:
+                        raise azexceptions.FatalAzureOperationException(
+                            code=-1,
+                            msg="A network to which to perform the migration "
+                                "must be specified within the target "
+                                "environment in order to add NIC '%s' to "
+                                "instance '%s'." % (
+                                    nic_name, instance_name)
+                        )
+
+                    LOG.info("'network_name' not provided for NIC '%s'. "
+                             "Attempting to attach to migration network '%s'.",
+                             nic_name, net_name)
+
+                    net_name = target_environment["destination_network"]["name"]
+                    subnet_name = target_environment[
+                        "destination_network"]["subnet"]
+                    add_public_ip = target_environment[
+                        "destination_network"]["is_public_network"]
+
+                else:
+                    network_map = target_environment.get("network_map", [])
+                    if not network_map:
+                        raise azexceptions.FatalAzureOperationException(
+                            code=-1,
+                            msg="A network map must be specified within the "
+                                "target environment in order to map the "
+                                "network of nic '%s', namely '%s'." % (
+                                    nic_name, net_name
+                                )
+                        )
+
+                    net = None
+                    for netm in network_map:
+                        if netm["source_network"] == net_name:
+                            net = netm
+                    if not net:
+                        raise azexceptions.FatalAzureOperationException(
+                            code=-1,
+                            msg="Cannot find suitable network name "
+                                "for attaching nic '%s' within the network "
+                                "map under the name '%s'" % (
+                                    nic_name, nic.get("network_name")
+                                )
+                        )
+
+                    net_name = net["destination_network"]
+                    subnet_name = net["destination_subnet"]
+                    add_public_ip = net["is_public_network"]
+
+                nic_name = azutils.normalize_resource_name(nic_name)
+
+                nic_ip_config = network.models.NetworkInterfaceIPConfiguration(
+                    name=nic_name + "-ipconf",
+                    subnet=self._get_subnet(
+                        connection_info,
+                        target_environment["resource_group"],
+                        net_name,
+                        subnet_name
+                    ),
+                    private_ip_allocation_method=network.models.IPAllocationMethod.dynamic,
+                )
+
+                if add_public_ip:
+                    ip_name = "%s-pip" % nic_name
+                    pub_ip = self._create_public_ip(
+                        connection_info,
+                        target_environment["resource_group"],
+                        ip_name, location
+                    )
+
+                    nic_ip_config.public_ip_address = pub_ip
+
+                nics.append(self._create_nic(
+                    connection_info,
+                    target_environment["resource_group"],
+                    nic_name,
+                    location,
+                    ip_configs=[nic_ip_config],
+                ))
+
+            # create the VM:
+            self._event_manager.progress_update(
+                "Starting migrated instance as '%s'" %
+                    azutils.normalize_resource_name(instance_name))
+
+            disk_name = AZURE_DISK_NAME_FORMAT % (
+                "".join(instance_name) + "_os_disk")
+            vm_profile = self._get_migration_os_profile(
+                export_info["os_type"], instance_name)
+
+            vmclient = self._get_compute_client(
+                connection_info).virtual_machines
+            utils.retry_on_error()(awaited(vmclient.create_or_update))(
+                target_environment["resource_group"],
+                azutils.normalize_resource_name(instance_name),
+                compute.models.VirtualMachine(
+                    location=location,
+                    os_profile=vm_profile,
+                    hardware_profile=compute.models.HardwareProfile(
+                        vm_size=compute.models.VirtualMachineSizeTypes(
+                            target_environment["size"]
+                        )
+                    ),
+                    network_profile=compute.models.NetworkProfile(
+                        network_interfaces=[compute.models.NetworkInterfaceReference(x.id)
+                                            for x in nics]
+                    ),
+                    storage_profile=compute.models.StorageProfile(
+                        os_disk=compute.models.OSDisk(
+                            name=disk_name,
+                            os_type=AZURE_OSTYPES_MAP[export_info["os_type"]],
+                            caching=compute.models.CachingTypes.none,
+                            create_option=compute.models.DiskCreateOptionTypes.from_image,
+                            # NOTE: we are guaranteed that the first disk is
+                            # the one with the OS inside it:
+                            image=worker_info.datadisks[0].vhd,
+                            vhd=compute.models.VirtualHardDisk(
+                                BLOB_PATH_FORMAT % (
+                                    target_environment["storage"]["account"],
+                                    CONF.azure_migration_provider.migr_container_name,
+                                    disk_name
+                                )
+                            ),
+                        ),
+                        # NOTE: we are guaranteed that the first disk in the
+                        # export_info is the one with the OS inside of it,
+                        # thus the rest are the datadisks:
+                        data_disks=worker_info.datadisks[1:],
+                    )
+                ),
+            )
+        except:
+            self._cleanup(connection_info, target_environment,
+                          resgroup, worker_info)
+            LOG.error(
+                "Exception occurred during import:\n" + traceback.format_exc())
+            raise
+        finally:
+            self._cleanup(connection_info, target_environment,
+                          resgroup, worker_info)
+
+    @utils.retry_on_error()
+    def _cleanup(self, connection_info, target_environment,
+                 migration_resgroup, worker_info):
+        """ Cleans up all resources for the migration. Is idempotent. """
+        awaited = azutils.awaited()
+
+        self._event_manager.progress_update("Cleaning up migration resource group")
+        resc = self._get_resource_client(connection_info)
+
+        # check if the migration resource group still exists:
+        if resc.resource_groups.check_existence(migration_resgroup):
+            # then, delete it:
+            awaited(resc.resource_groups.delete)(migration_resgroup)
+
+        if not worker_info:
+            # it means that the worker never got defined, and we may return:
+            return
+
+        worker_name = worker_info.name
+
+        # lastly, delete the worker's disks:
+        # delete worker disks:
+        blobd = self._get_page_blob_client(target_environment)
+        cont_name = CONF.azure_migration_provider.migr_container_name
+        blob_name = AZURE_DISK_NAME_FORMAT % worker_name
+
+        if blobd.exists(cont_name, blob_name):
+            blobd.delete_blob(cont_name, blob_name)
+
+        self._delete_recovery_disk(target_environment, worker_name)

+ 20 - 0
coriolis/providers/azure/exceptions.py

@@ -0,0 +1,20 @@
+# Copyright 2016 Cloudbase Solutions Srl
+# All Rights Reserved.
+
+"""
+Defines various exceptions which may arise during migrations to/from Azure.
+"""
+
+from coriolis import exception
+from coriolis.i18n import _
+
+
+class AzureOperationException(exception.CoriolisException):
+    """ Simply wraps a standard CoriolisException. """
+    message = _("Azure operation failed with code: %(code)d. "
+                "Error message: %(msg)s.")
+
+
+class FatalAzureOperationException(AzureOperationException):
+    """ Extends AzureOperationExceptions to indicate fatal errors. """
+    pass

+ 57 - 0
coriolis/providers/azure/schemas/connection_info_schema.json

@@ -0,0 +1,57 @@
+{
+  "$schema": "http://cloudbase.it/coriolis/schemas/azure_connection#",
+  "type": "object",
+  "properties": {
+    "secret_ref": {
+      "type": "string"
+    },
+    "subscription_id": {
+      "type": "string"
+    },
+    "user_credentials": {
+      "type": "object",
+      "properties": {
+        "username": {
+          "type": "string"
+        },
+        "password": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "username",
+        "password"
+      ]
+    },
+    "service_principal_credentials": {
+      "type": "object",
+      "properties": {
+        "client_id": {
+          "type": "string"
+        },
+        "client_secret": {
+          "type": "string"
+        },
+        "tenant_id": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "client_id",
+        "client_secret",
+        "tenant_id"
+      ]
+    }
+  },
+  "oneOf": [
+    {
+      "required": ["secret_ref"]
+    },
+    {
+      "required": ["subscription_id", "user_credentials"]
+    },
+    {
+      "required": ["subscription_id", "service_principal_credentials"]
+    }
+  ]
+}

+ 98 - 0
coriolis/providers/azure/schemas/target_environment_schema.json

@@ -0,0 +1,98 @@
+{
+  "$schema": "http://cloudbase.it/coriolis/schemas/azure_target_environment#",
+  "type": "object",
+  "properties": {
+    "secret_ref": {
+      "type": "string"
+    },
+    "size": {
+      "type": "string"
+    },
+    "location": {
+      "type": "string"
+    },
+    "resource_group": {
+      "type": "string"
+    },
+    "storage": {
+      "type": "object",
+      "properties": {
+        "account": {
+          "type": "string"
+        },
+        "key": {
+          "type": "string"
+        },
+        "container": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "account",
+        "key"
+      ]
+    },
+    "migr_image_sku": {
+        "type": "string"
+    },
+    "migr_image_sku_map": {
+        "type": "object"
+    },
+    "network_map": {
+      "type": "array",
+      "items": {
+        "type": "object",
+        "properties": {
+          "source_network": {
+            "type": "string"
+          },
+          "destination_network": {
+            "type": "string"
+          },
+          "destination_subnet_name": {
+            "type": "string"
+          },
+          "is_public_network": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "source_network",
+          "destination_network",
+          "destination_subnet",
+          "is_public_network"
+        ]
+      }
+    },
+    "destination_network": {
+      "type": "object",
+      "properties": {
+        "name": {
+          "type": "string"
+        },
+        "subnet": {
+          "type": "string"
+        },
+        "is_public_network": {
+          "type": "boolean"
+        }
+      },
+      "required": [
+        "name",
+        "subnet",
+        "is_public_network"
+      ]
+    }
+  },
+  "oneOf": [
+    {
+      "required": ["secret_ref"]
+    },
+    {
+      "required": ["size", "location", "resource_group", "storage", "destination_network"]
+    },
+    {
+      "required": ["size", "location", "resource_group", "storage", "network_map"]
+    }
+  ]
+}

+ 101 - 0
coriolis/providers/azure/utils.py

@@ -0,0 +1,101 @@
+# Copyright 2016 Cloudbase Solutions Srl
+# All Rights Reserved.
+
+"""
+General utility functions for performing Azure operations.
+"""
+
+import functools
+import random
+import string
+import uuid
+
+from oslo_config import cfg
+from oslo_log import log as logging
+
+
+CONF = cfg.CONF
+LOG = logging.getLogger(__name__)
+
+
+def get_random_password():
+    """ Returns a random password compatible with the minimal requirements of
+    Azure to be used for worker instances (namely, to contain 8+ characters
+    with any 3 of: a character (lower or uppoercase), digit or special symbol).
+    """
+    upper = random.choice(string.ascii_uppercase)
+    lower = random.choice(string.ascii_lowercase)
+    digit = random.choice(string.digits)
+
+    return "%s%s%s%s" % (
+        upper, digit, lower, get_unique_id()
+    )
+
+
+def get_unique_id():
+    """ Returns a generically Azure-friendly ID. """
+    return "".join([x for x in str(uuid.uuid4()) if x.isalnum()])
+
+
+def normalize_resource_name(name):
+    """ Normalizes a resource name.
+
+    Constraints are:
+        - alphanumeric characters or '.', '-', '_'
+        - maximum length is 80
+    """
+    return "".join([x for x in name if x.isalnum() or x in ['.', '-', '_']])
+
+
+def normalize_hostname(hostname):
+    """ Normalizes the provided hostname for Azure.
+
+    Constraints are:
+        - no special characters
+        - maximum length of 15
+    """
+    new = "".join([x for x in hostname if x.isalnum])
+    if not new:
+        new = CONF.azure_migration_provider.default_migration_hostname
+        LOG.info("Cannot normalize instance hostname '%s' for Azure. "
+                 "Defaulting to using '%s'.", hostname, new)
+
+    if len(new) > 15:
+        new = new[:15]
+
+    if new != hostname:
+        LOG.info("Normalized instance hostname '%s' to '%s' "
+                 "for booting instance on Azure.", new, hostname)
+
+    return new
+
+
+def normalize_location(location):
+    """ Normalizes a location for azure. """
+    return "".join([x.lower() for x in location if x.isalnum()])
+
+
+def checked(operation):
+    """ Forces raw status code check on an Azure operation. """
+    @functools.wraps(operation)
+    def _checked(*args, **kwargs):
+        resp = operation(*args, raw=True, **kwargs)
+
+        return resp.output
+
+    return _checked
+
+
+def awaited(timeout=90):
+    """ Awaits for the result of the given operation. """
+
+    def _awaited(operation):
+        @functools.wraps(operation)
+        def _await(*args, **kwargs):
+            resp = operation(
+                *args, long_running_operation_timeout=timeout, **kwargs)
+
+            return resp.result()
+        return _await
+
+    return _awaited

+ 3 - 1
coriolis/providers/factory.py

@@ -1,5 +1,6 @@
 from coriolis import constants
 from coriolis import exception
+from coriolis.providers import azure
 from coriolis.providers import openstack
 from coriolis.providers import vmware_vsphere
 
@@ -10,7 +11,8 @@ EXPORT_PROVIDERS = {
 }
 
 IMPORT_PROVIDERS = {
-    constants.PLATFORM_OPENSTACK: openstack.ImportProvider
+    constants.PLATFORM_OPENSTACK: openstack.ImportProvider,
+    constants.PLATFORM_AZURE_RM: azure.ImportProvider
 }
 
 

+ 3 - 0
coriolis/schemas/vm_export_info_schema.json

@@ -132,6 +132,9 @@
               },
               "mac_address": {
                 "$ref": "#/definitions/nullableString"
+              },
+              "subnet_name": {
+                "type": "string"
               }
             },
             "required": [

+ 0 - 0
coriolis/tests/providers/__init__.py


+ 0 - 0
coriolis/tests/providers/azure/__init__.py


+ 427 - 0
coriolis/tests/providers/azure/test_azure_import.py

@@ -0,0 +1,427 @@
+import os
+import tempfile
+
+import mock
+from oslo_config import cfg
+from oslo_utils import units
+from azure.mgmt import compute, network
+
+from coriolis import constants
+from coriolis.providers import azure
+from coriolis.providers.azure import exceptions
+from coriolis.tests import testutils
+from coriolis.tests.providers import base
+
+
+class AzureImportProviderUnitTestsCase(base.ImportProviderTestCase):
+
+    _platform = constants.PLATFORM_AZURE_RM
+    _hypervisor = constants.HYPERVISOR_HYPERV
+
+    def setUp(self):
+        super(AzureImportProviderUnitTestsCase, self).setUp()
+
+        self._patch_utils_retry()
+
+        self._provider = azure.ImportProvider(self._mock_event_manager)
+
+        event_manager_patcher = mock.patch.object(
+            self._provider, '_event_manager',
+            new=self._mock_event_manager)
+        event_manager_patcher.start()
+
+        self._test_location = mock.sentinel.test_location
+        self._test_migration_id = mock.sentinel.test_migration_id
+        self._test_container_name = mock.sentinel.test_container
+        self._test_storage_name = mock.sentinel.test_storage_account
+        self._test_resource_group = mock.sentinel.test_resource_group
+        self._test_subnet_name = mock.sentinel.test_subnet_name
+
+        # various mocks for various helpers in azure.utils.
+        # to patch them '_patch_azure_utils' should be called.
+        self._test_random_password = mock.sentinel.test_azutils_random_password
+        self._mock_azutils_randpass = mock.MagicMock(
+            return_value=self._test_random_password)
+
+        self._test_unique_id = mock.sentinel.test_azutils_unique_id
+        self._mock_azutils_uniqueid = mock.MagicMock(
+            return_value=self._test_unique_id)
+
+        self._test_normalized_location = mock.sentinel.test_normalized_location
+        self._mock_azutils_normalize_location = mock.MagicMock(
+            return_value=self._test_normalized_location)
+
+        self._patch_azure_decorators()
+        self._setup_config_mock()
+
+    def _setup_config_mock(self):
+        azure_provider_conf = mock.MagicMock(
+            migr_container_name=self._test_container_name,
+            migr_subnet_name=self._test_subnet_name
+        )
+
+        conf_patcher = mock.patch.object(
+            cfg.CONF, 'azure_migration_provider', new=azure_provider_conf)
+        conf_patcher.start()
+
+    def _patch_azure_utils(self):
+        azutils_patcher = mock.patch.multiple(
+            'coriolis.providers.azure.azutils',
+            get_random_password=self._mock_azutils_randpass,
+            get_unique_id=self._mock_azutils_uniqueid,
+            normalize_location=self._mock_azutils_normalize_location
+        )
+        azutils_patcher.start()
+
+    def _patch_azure_decorators(self):
+        # NOTE: we patch the standard decorators found in azutils, whose core
+        # functionality lies in injecting the 'raw=True' kwarg to all ARM API
+        # calls which forces more checkable output/waitable long running
+        # operations (checked and awaited, respectively).
+        # Considering it's ideally a background thing; no tests focus on
+        # checking for that kwarg (except the tests for the decorators
+        # themselves...).
+        self._mock_checked = testutils.make_identity_decorator_mock()
+
+        self._mock_awaited_inner = testutils.make_identity_decorator_mock()
+        def awaited_dec(*args, **kwargs):
+            return self._mock_awaited_inner
+        self._mock_awaited = mock.MagicMock(side_effect=awaited_dec)
+
+        decs_patcher = mock.patch.multiple(
+            'coriolis.providers.azure.azutils',
+            checked=self._mock_checked,
+            awaited=self._mock_awaited
+        )
+        decs_patcher.start()
+
+    @mock.patch.object(azure.ImportProvider, '_get_block_blob_client')
+    def test_delete_recovery_disk(self, mock_get_blobc):
+        interesting_blob_name = ("%s.veryinteresting.status" %
+                                 self._test_instance_name)
+        blob_names = [
+            "uninteresting", interesting_blob_name, "boring"
+        ]
+        # NOTE: small workaround mocks already having a 'name' attribute:
+        mock_blobs = []
+        for name in blob_names:
+            m = mock.MagicMock()
+            m.name = name
+            mock_blobs.append(m)
+
+        mock_blob_client = mock.MagicMock()
+        mock_blob_client.list_blobs.return_value = mock_blobs
+
+        mock_get_blobc.return_value = mock_blob_client
+
+        self._provider._delete_recovery_disk(
+            self._test_target_env, self._test_instance_name)
+
+        mock_get_blobc.assert_called_once_with(self._test_target_env)
+
+        mock_blob_client.list_blobs.assert_called_once_with(
+            self._test_container_name)
+        mock_blob_client.delete_blob.assert_called_once_with(
+            self._test_container_name, interesting_blob_name)
+
+    @mock.patch.object(azure.ImportProvider, '_get_page_blob_client')
+    def test_upload_disk(self, mock_get_pagec):
+        test_env = {"storage": {"account": self._test_storage_name}}
+        test_disk_path = mock.sentinel.test_disk_path
+        test_upload_name = mock.sentinel.test_upload_name
+
+        mock_page_client = mock.MagicMock()
+        mock_get_pagec.return_value = mock_page_client
+
+        res = self._provider._upload_disk(
+            test_env, test_disk_path, test_upload_name)
+
+        disk_uri = azure.BLOB_PATH_FORMAT % (
+            self._test_storage_name,
+            self._test_container_name,
+            test_upload_name
+        )
+
+        mock_get_pagec.assert_called_once_with(test_env)
+        mock_page_client.create_blob_from_path.assert_called_once_with(
+            self._test_container_name, test_upload_name,
+            test_disk_path, progress_callback=mock.ANY)
+
+        self.assertEqual(res.name, test_upload_name)
+        self.assertEqual(res.uri, disk_uri)
+
+    @mock.patch.object(azure.ImportProvider, '_get_network_client')
+    def test_create_migration_network(self, mock_get_netc):
+        target_env = {"location": self._test_location}
+
+        # NOTE: this definition should remain rigid throughout as it has no
+        # real reason of being migration-specific.
+        test_vnet = network.models.VirtualNetwork(
+            location=self._test_location,
+            address_space=network.models.AddressSpace(
+                address_prefixes=["10.0.0.0/16"]
+            ),
+            subnets=[
+                network.models.Subnet(
+                    name=self._test_subnet_name,
+                    address_prefix='10.0.0.0/24'
+                )
+            ]
+        )
+
+        resgroup_name = azure.MIGRATION_RESGROUP_NAME_FORMAT % (
+            self._test_migration_id)
+        vn_name = azure.MIGRATION_NETWORK_NAME_FORMAT % (
+            self._test_migration_id)
+
+        mock_vnetc = mock.MagicMock()
+        mock_vnetc.create_or_update.return_value = test_vnet
+        mock_vnetc.get.return_value = test_vnet
+
+        mock_netc = mock.MagicMock()
+        mock_netc.virtual_networks = mock_vnetc
+
+        mock_get_netc.return_value = mock_netc
+
+        res = self._provider._create_migration_network(
+            self._test_conn_info, target_env, self._test_migration_id)
+
+        self.assertEqual(res, test_vnet)
+
+        mock_get_netc.assert_called_once_with(self._test_conn_info)
+
+        mock_vnetc.create_or_update.assert_called_once_with(
+            resgroup_name, vn_name, test_vnet)
+
+        mock_vnetc.get.assert_called_once_with(resgroup_name, vn_name)
+        self._mock_awaited_inner.assert_called_once_with(
+            mock_vnetc.create_or_update)
+        self._mock_checked.assert_called_once_with(mock_vnetc.get)
+
+    @mock.patch.object(azure.ImportProvider, '_get_compute_client')
+    def test_wait_for_vm_success(self, mock_get_computec):
+        vm_states = [
+            mock.Mock(provisioning_state=s) for s in
+            ["Creating", "Creating", "Updating", "Creating", "Succeeded"]
+        ]
+
+        mock_vmclient = mock.MagicMock()
+        mock_vmclient.get.side_effect = vm_states
+
+        mock_get_computec.return_value = mock.MagicMock(
+            virtual_machines=mock_vmclient)
+
+        self._provider._wait_for_vm(
+            self._test_conn_info,
+            self._test_resource_group,
+            self._test_instance_name,
+            period=0
+        )
+
+        mock_get_computec.assert_called_once_with(self._test_conn_info)
+
+        mock_vmclient.get.assert_has_calls([
+            mock.call(self._test_resource_group, self._test_instance_name)
+            for _ in vm_states
+        ])
+        self._mock_checked.assert_has_calls([
+            mock.call(mock_vmclient.get) for _ in vm_states
+        ])
+
+    @mock.patch.object(azure.ImportProvider, '_get_linux_worker_osprofile')
+    @mock.patch.object(azure.ImportProvider, '_get_windows_worker_osprofile')
+    def test_get_worker_osprofile(self, mock_get_windows_osprofile,
+                                  mock_get_linux_osprofile):
+        windows = constants.OS_TYPE_WINDOWS
+        linux = constants.OS_TYPE_LINUX
+        random_export_info = {"os_type": "some random OS"}
+
+        worker_name = mock.sentinel.worker_name
+        windows_profile = mock.sentinel.windows_osprofile
+        linux_profile = mock.sentinel.linux_osprofile
+
+        mock_get_windows_osprofile.return_value = windows_profile
+        mock_get_linux_osprofile.return_value = linux_profile
+
+        self._provider._get_worker_osprofile(
+            windows, self._test_location, worker_name)
+
+        mock_get_windows_osprofile.assert_called_once_with(
+            self._test_location, worker_name)
+        mock_get_linux_osprofile.assert_not_called()
+
+        mock_get_windows_osprofile.reset_mock()
+        mock_get_linux_osprofile.reset_mock()
+
+        self._provider._get_worker_osprofile(
+            linux, self._test_location, worker_name)
+
+        mock_get_linux_osprofile.assert_called_once_with(worker_name)
+        mock_get_windows_osprofile.assert_not_called()
+
+        mock_get_windows_osprofile.reset_mock()
+        mock_get_linux_osprofile.reset_mock()
+
+        self.assertRaises(
+            exceptions.FatalAzureOperationException,
+            self._provider._get_worker_osprofile,
+            random_export_info, self._test_location, worker_name)
+
+        mock_get_linux_osprofile.assert_not_called()
+        mock_get_windows_osprofile.assert_not_called()
+
+    @mock.patch.object(azure.ImportProvider, '_get_network_client')
+    def test_create_nic(self, mock_get_netc):
+
+        test_nic_name = mock.sentinel.test_nic_name
+        test_ip_configs = mock.sentinel.test_ip_configs
+
+        test_nic = network.models.NetworkInterface(
+            location=self._test_location,
+            ip_configurations=test_ip_configs
+        )
+
+        mock_nicc = mock.MagicMock()
+        mock_nicc.create_or_update.return_value = test_nic
+        mock_nicc.get.return_value = test_nic
+
+        mock_get_netc.return_value = mock.MagicMock()
+        mock_get_netc.return_value.network_interfaces = mock_nicc
+
+        res = self._provider._create_nic(
+            self._test_conn_info, self._test_resource_group, test_nic_name,
+            self._test_location, test_ip_configs)
+
+        mock_get_netc.assert_called_once_with(self._test_conn_info)
+
+        self._mock_awaited_inner.assert_called_once_with(
+            mock_nicc.create_or_update)
+        mock_nicc.create_or_update.assert_called_once_with(
+            self._test_resource_group, test_nic_name, test_nic)
+
+        self._mock_checked.assert_called_once_with(mock_nicc.get)
+        mock_nicc.get.assert_called_once_with(
+            self._test_resource_group, test_nic_name)
+
+        self.assertEqual(res, test_nic)
+
+    @mock.patch.object(azure.ImportProvider, '_get_network_client')
+    def test_create_public_ip(self, mock_get_netc):
+        test_ip_name = mock.sentinel.test_pulic_ip_name
+
+        test_pip = network.models.PublicIPAddress(
+            location=self._test_location,
+            public_ip_allocation_method=
+            network.models.IPAllocationMethod.dynamic
+        )
+
+        mock_pipc = mock.MagicMock()
+        mock_pipc.create_or_update.return_value = test_pip
+        mock_pipc.get.return_value = test_pip
+
+        mock_get_netc.return_value = mock.MagicMock()
+        mock_get_netc.return_value.public_ip_addresses = mock_pipc
+
+        res = self._provider._create_public_ip(
+            self._test_conn_info, self._test_resource_group,
+            test_ip_name, self._test_location)
+
+        mock_get_netc.assert_called_once_with(self._test_conn_info)
+
+        self._mock_awaited_inner.assert_called_once_with(
+            mock_pipc.create_or_update)
+        mock_pipc.create_or_update.assert_called_once_with(
+            self._test_resource_group, test_ip_name, test_pip)
+
+        self._mock_checked.assert_called_once_with(mock_pipc.get)
+        mock_pipc.get.assert_called_once_with(
+            self._test_resource_group, test_ip_name)
+
+        self.assertEqual(res, test_pip)
+
+    @mock.patch.object(os, 'remove')
+    @mock.patch.object(os.path, 'splitext')
+    def test_convert_to_vhd(self, mock_splitext, mock_osremove):
+        self._patch_utils_disk_functions()
+        self._patch_azure_utils()
+
+        test_disk_path = mock.sentinel.test_disk_path
+
+        test_newpath = mock.sentinel.test_new_diskpath
+        mock_splitext.return_value = ["testdiskname"]
+
+        test_newpath = "%s.%s" % (
+            mock_splitext.return_value[0],
+            constants.DISK_FORMAT_VHD
+        )
+
+        res = self._provider._convert_to_vhd(test_disk_path)
+
+        self._mock_utils_convert_disk.assert_called_once_with(
+            test_disk_path, test_newpath, constants.DISK_FORMAT_VHD,
+            preallocated=True)
+
+        self.assertEqual(res, test_newpath)
+
+    @mock.patch.object(os, 'remove')
+    @mock.patch.object(azure.ImportProvider, '_upload_disk')
+    @mock.patch.object(azure.ImportProvider, '_convert_to_vhd')
+    def _test_migrate_disk(self, mock_convert, mock_upload_disk, mock_osremove,
+            test_disk_format=None):
+        self._patch_utils_disk_functions()
+
+        test_disk_info = {
+            "virtual-size": 1 * units.Gi,
+            "format": test_disk_format
+        }
+        self._mock_utils_get_disk_info.return_value = test_disk_info
+
+        test_lun = mock.sentinel.test_lun
+        test_upload_name = mock.sentinel.test_upload_name
+        test_disk_path = mock.sentinel.test_disk_path
+
+        osremove_calls = [mock.call(test_disk_path)] # should get deleted anyhow...
+
+        test_blob_uri = mock.sentinel.test_blob_uri
+        test_blob_name = mock.sentinel.test_blob_name
+        test_blob = azure.AzureStorageBlob(
+            name=test_blob_name,
+            uri=test_blob_uri
+        )
+        mock_upload_disk.return_value = test_blob
+
+        expected_upload_path = test_disk_path
+        expected_data_disk = compute.models.DataDisk(
+            lun=test_lun,
+            disk_size_gb=2,
+            name=test_blob_name,
+            caching=compute.models.CachingTypes.none,
+            vhd=compute.models.VirtualHardDisk(uri=test_blob_uri),
+            create_option=compute.models.DiskCreateOptionTypes.attach
+        )
+
+        test_upload_path = mock.sentinel.test_upload_path
+        mock_convert.return_value = test_upload_path
+
+        res = self._provider._migrate_disk(
+            self._test_target_env, test_lun, test_disk_path, test_upload_name)
+
+        self._mock_utils_get_disk_info.assert_called_once_with(test_disk_path)
+
+        if test_disk_format != constants.DISK_FORMAT_VHD:
+            mock_convert.assert_called_once_with(test_disk_path)
+            osremove_calls.append(mock.call(test_upload_path))
+            expected_upload_path = test_upload_path
+
+        mock_upload_disk.assert_called_once_with(
+            self._test_target_env, expected_upload_path, test_upload_name)
+
+        mock_osremove.assert_has_calls(osremove_calls)
+
+        self.assertEqual(res, expected_data_disk)
+
+    def test_migrate_disk(self):
+        self._test_migrate_disk(test_disk_format=constants.DISK_FORMAT_VHD)
+
+    def test_migrate_disk_with_conversion(self):
+        self._test_migrate_disk(test_disk_format="somerandomformat")

+ 80 - 0
coriolis/tests/providers/base.py

@@ -0,0 +1,80 @@
+""" Defines base classes for all provider tests. """
+
+import mock
+
+from coriolis.tests import testutils
+from coriolis.tests import test_base
+
+
+class ProvidersBaseTestCase(test_base.CoriolisBaseTestCase):
+
+    def setUp(self):
+        super(ProvidersBaseTestCase, self).setUp()
+
+        self._mock_event_manager = mock.MagicMock()
+        self._mock_context = mock.MagicMock()
+        self._test_conn_info = mock.sentinel.test_connection_info
+        self._test_instance_name = mock.sentinel.test_instance_name
+
+        self._mock_morph = mock.MagicMock()
+        osmorphing_patcher = mock.patch(
+            'coriolis.osmorphing.manager.morph_image', new=self._mock_morph)
+        osmorphing_patcher.start()
+
+        # NOTE: declare utils.retry_on_error mock; '_patch_utils_retry' should
+        # be called to enable them.
+        self._mock_utils_retry_inner = testutils.make_identity_decorator_mock()
+        def retry_dec(*args, **kwargs):
+            return self._mock_utils_retry_inner
+        self._mock_utils_retry = mock.MagicMock(side_effect=retry_dec)
+
+        # NOTE: declare various utils disk helpers mocks;
+        # '_patch_utils_disk_functions' should be called to enable them...
+        self._mock_utils_get_disk_info = mock.MagicMock()
+        self._mock_utils_convert_disk = mock.MagicMock()
+
+        self.addCleanup(mock.patch.stopall)
+
+    def _patch_utils_retry(self):
+        retry_patcher = mock.patch(
+            'coriolis.utils.retry_on_error', self._mock_utils_retry)
+        retry_patcher.start()
+
+    def _patch_utils_disk_functions(self):
+        utils_patcher = mock.patch.multiple(
+            'coriolis.utils',
+            get_disk_info=self._mock_utils_get_disk_info,
+            convert_disk_format=self._mock_utils_convert_disk)
+        utils_patcher.start()
+
+
+class ImportProviderTestCase(ProvidersBaseTestCase):
+
+    @property
+    def _platform(self):
+        raise NotImplementedError("Missing platform type.")
+
+    @property
+    def _hypervisor(self):
+        raise NotImplementedError("Missing hypervisor type.")
+
+    def setUp(self):
+        super(ImportProviderTestCase, self).setUp()
+
+        self._test_target_env = mock.sentinel.target_environment
+        self._test_export_info = mock.sentinel.export_info
+
+    def _test_morphing_called(self, os_type="", nics_info=None, ignore_devs=[]):
+        self._mock_morph.morph_image.assert_called_once_with(
+            self._mock_conn_info, os_type,
+            self._hypervisor, self._platform,
+            nics_info, self._mock_event_manager,
+            ignore_devices=ignore_devs)
+
+
+class ExportProviderTestCase(ProvidersBaseTestCase):
+
+    def setUp(self):
+        super(ExportProviderTestCase, self).setUp()
+
+        self._export_path = mock.sentinel.export_path

+ 0 - 2
coriolis/tests/test_base.py

@@ -1,7 +1,5 @@
 """ Defines base class for all tests. """
 
-import mock
-
 from oslotest import base
 
 

+ 12 - 0
coriolis/tests/testutils.py

@@ -0,0 +1,12 @@
+""" Defines general utilities for all tests. """
+
+import mock
+
+
+def identity_dec(item, *args, **kwargs):
+    """ A decorator which adds nothing to the decorated item. """
+    return item
+
+def make_identity_decorator_mock():
+    """ Returns a MagicMock with identity_dec as a side-effect. """
+    return mock.MagicMock(side_effect=identity_dec)

+ 23 - 4
coriolis/utils.py

@@ -39,7 +39,8 @@ def ignore_exceptions(func):
     return _ignore_exceptions
 
 
-def retry_on_error(max_attempts=5, sleep_seconds=0):
+def retry_on_error(max_attempts=5, sleep_seconds=0,
+                   terminal_exceptions=[]):
     def _retry_on_error(func):
         @functools.wraps(func)
         def _exec_retry(*args, **kwargs):
@@ -48,6 +49,10 @@ def retry_on_error(max_attempts=5, sleep_seconds=0):
                 try:
                     return func(*args, **kwargs)
                 except Exception as ex:
+                    if any([isinstance(ex, tex)
+                            for tex in terminal_exceptions]):
+                        raise
+
                     i += 1
                     if i < max_attempts:
                         LOG.warn("Exception occurred, retrying: %s", ex)
@@ -175,11 +180,25 @@ def get_disk_info(disk_path):
     return disk_info
 
 
-def convert_disk_format(disk_path, target_disk_path, target_format):
+def convert_disk_format(disk_path, target_disk_path, target_format,
+                        preallocated=False):
+    allocation_args = []
+
+    if preallocated:
+        if target_format != constants.DISK_FORMAT_VHD:
+            raise NotImplementedError(
+                "Preallocation is supported only for the VHD format.")
+
+        allocation_args = ['-o', 'subformat=fixed']
+
     if target_format == constants.DISK_FORMAT_VHD:
         target_format = "vpc"
-    exec_process([CONF.qemu_img_path, 'convert', '-O', target_format,
-                  disk_path, target_disk_path])
+
+    args = ([CONF.qemu_img_path, 'convert', '-O', target_format] +
+            allocation_args +
+            [disk_path, target_disk_path])
+
+    exec_process(args)
 
 
 def get_hostname():

+ 7 - 0
requirements.txt

@@ -30,3 +30,10 @@ PyYAML
 requests
 sqlalchemy
 webob
+msrest==0.4.0
+msrestazure==0.4.1
+azure-mgmt-compute==0.30.0rc5
+azure-mgmt-network==0.30.0rc5
+azure-mgmt-resource==0.30.0rc5
+azure-mgmt-storage==0.30.0rc5
+azure-storage==0.32.0