Copy-Disk.ps1

<#
.SYNOPSIS
Copy a disk to the cloud.

.DESCRIPTION
Copy a disk to a cloud platform. The currently supported platforms are Azure, AWS and GCP.

.PARAMETER ConfigJsonFile
Specifies a file containing JSON configuration.

.PARAMETER CustomerId
Citrix customer ID.

.PARAMETER SmbHost
Specifies the hostname of the SMB server where the disk to be copied is located.

.PARAMETER SmbPort
Specifies the port number of the SMB server where the disk to be copied is located.

.PARAMETER SmbShare
Specifies the name of the SMB share where the disk to be copied is located.

.PARAMETER SmbPath
Specifies the path in the SMB share (excluding the file name) to the disk to be copied.

.PARAMETER SmbDiskName
Specifies the filename of the disk to be copied.

.PARAMETER SmbDiskFormat
Specifies the format of the disk to be copied. Must be one of "VhdDiskFormat" or "VhdxDiskFormat". "VhdDiskFormat" is the default.

.PARAMETER CloudPlatform
Specifies the cloud platform to copy the disk to. Must be one of "aws", "azure" or "gcp".

.PARAMETER CloudDiskName
Specifies the name of the Azure managed disk or Google Cloud image to copy the disk to.

.PARAMETER SmbUserDomain
Specifies the domain name of the account to use for authenticating to the SMB share where the disk to be copied is located.

.PARAMETER SmbUserName
Specifies the user name of the account to use for authenticating to the SMB share where the disk to be copied is located.

.PARAMETER SmbCred
Specifies a credential to use for authenticating to the SMB share where the disk to be copied is located.

.PARAMETER UploadTimeout
Specifies a timeout for the upload of the disk. Applies to Azure only.

.PARAMETER Threads
Specifies the number of threads to use for uploading to the cloud.

.PARAMETER Install
Install required powershell modules. Applies to Azure only.

.PARAMETER LogFile
Specifies the path to the file to log to. ".\Upload.log" is the default.

.PARAMETER OverwriteLog
If specified the log file is overwritten otherwise it is appended to.

.PARAMETER Force
If the destination of the copy already exists delete it before doing the copy.

.PARAMETER AwsRegion
Specifies the AWS region to create the snapshot in.

.PARAMETER AwsProfileName
Specifies the name of the profile containing the AWS credentials to use.

.PARAMETER AzureSubscriptionId
Specifies the ID of the Azure subscription the target resource group is in.

.PARAMETER AzureLocation
Specifies the Azure location to create the managed disk in..

.PARAMETER TargetResourceGroup
Specifies the resource group to create the managed disk in.

.PARAMETER AzureStorageType
Specifies the storage type to use for the managed disk. Must be one of "Standard_LRS", "Premium_LRS", "StandardSSD_LRS", "Premium_ZRS" or "
StandardSSD_ZRS". "Premium_LRS" is the default.

.PARAMETER AzureClientId
Azure client ID.

.PARAMETER AzureSecret
Azure client secret.

.PARAMETER AzureTenantId
Azure tenant ID.

.PARAMETER GcpServiceAccountKeyFile
Specifies the name of a file containing Google Cloud service principal credentials.

.INPUTS
None.

.OUTPUTS
System.String. When copying to AWS the ID of the EC2 snapshot the disk is copied to is returned.

.EXAMPLE

PS> @CopyParams = @{
  CloudPlatform = 'aws'
  SmbHost = 'share.example.com'
  SmbShare = 'ips'
  SmbPath = 'staging/disks'
  SmbDiskName = 'test'
  SmbDiskFormat = 'VhdDiskFormat'
  SmbUserDomain = 'users'
  SmbUserName = 'demo'
}

PS> Copy-Disk @CopyParams -LogFile aws-copy.log -OverwriteLog

snap-b4191a9783f1f568e

.EXAMPLE

PS> $CopyParams = @{
  CloudPlatform = 'azure'
  SmbHost = 'share.example.com'
  SmbShare = 'ips'
  SmbPath = 'staging/disks'
  SmbDiskName = 'test'
  SmbDiskFormat = 'VhdDiskFormat'
  SmbUserDomain = '.'
  SmbUserName = 'demo'
  AzureSubscriptionId = 'efa90dc7-c7d7-4f7b-b633-80a896a56b11'
  AzureLocation = 'eastus'
  TargetResourceGroup = 'ips-test'
  CloudDiskName = 'test'
  AzureSecret = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
  AzureClientId = 'a8470b17-f549-42f4-8802-0f81234046a0'
  AzureTenantId = 'ff6cec26-b5c6-4b5c-9b9b-d427ab131a2f'
}

PS> Copy-Disk @CopyParams

.EXAMPLE

PS> $CopyParams = @{
  CloudPlatform = 'gcp'
  SmbHost = 'share.example.com'
  SmbShare = 'ips'
  SmbPath = 'staging/disks'
  SmbDiskName = 'test'
  SmbDiskFormat = 'VhdDiskFormat'
  SmbUserDomain = 'users'
  SmbUserName = 'demo'
  SmbDiskFormat = 'VhdDiskFormat'
  SmbUserDomain = 'citrite'
  SmbUserName = 'nicholasn'
  CloudDiskName = 'nicholasn-test'
  GcpServiceAccountKeyFile = 'demo-project-af94dadb30a1.json'
}

PS> Copy-Disk @CopyParams -LogFile gcp-copy.log

#>


Function Copy-Disk
{
    [CmdletBinding(DefaultParameterSetName = 'cmd')]
    [Obsolete("Use one of Copy-DiskToAWS, Copy-DiskToAzure or Copy-DiskToGCP.")]
    Param(
        [Parameter(Mandatory = $True, ParameterSetName = 'file')]
        [string] $ConfigJsonFile,
        
        [Parameter(ParameterSetName = 'cmd')]
        [string] $CustomerId,
        
        [Parameter(Mandatory = $True, ParameterSetName = 'cmd')]
        [string] $CloudPlatform,
        
        [Parameter(Mandatory = $True, ParameterSetName = 'cmd')]
        [string] $SmbHost,
        
        [Parameter(ParameterSetName = 'cmd')]
        [string] $SmbPort = $null,
        
        [Parameter(Mandatory = $True, ParameterSetName = 'cmd')]
        [string] $SmbShare,
        
        [Parameter(ParameterSetName = 'cmd')]
        [string] $SmbPath,
        
        [Parameter(Mandatory = $True, ParameterSetName = 'cmd')]
        [string] $SmbDiskName,
        
        [Parameter(ParameterSetName = 'cmd')]
        [string] $SmbDiskFormat = "VhdDiskFormat",
        
        [Parameter(Mandatory = $True, ParameterSetName = 'cmd')]
        [string] $SmbUserDomain,
        
        [Parameter(Mandatory = $True, ParameterSetName = 'cmd')]
        [string] $SmbUserName,
        
        [Parameter(ParameterSetName = 'cmd')]
        [string] $AzureSubscriptionId,
        
        [Parameter(ParameterSetName = 'cmd')]
        [string] $AzureLocation,
        
        [Parameter(ParameterSetName = 'cmd')]
        [string] $TargetResourceGroup,
        
        [Parameter(ParameterSetName = 'cmd')]
        [string] $CloudDiskName,
        
        [Parameter(ParameterSetName = 'cmd')]
        [int] $UploadTimeout = 36000,
        
        [Parameter(ParameterSetName = 'cmd')]
        [string] $AzureStorageType = "Premium_LRS",
        
        [Parameter(ParameterSetName = 'cmd')]
        [int] $Threads,
        
        [Parameter()]
        [string] $AzureClientId,
         
        [Parameter()]
        [string] $AzureSecret,
         
        [Parameter()]
        [string] $AzureTenantId,
         
        [Parameter()]
        [string] $AwsRegion,
         
        [Parameter()]
        [string] $AwsProfileName,
         
        [Parameter()]
        [string] $GcpServiceAccountKeyFile,
         
        [Parameter()]
        [pscredential] $SmbCred,
         
        [Parameter()]
        [switch] $Install,
         
        [Parameter()]
        [string] $LogFile,

        [Parameter()]
        [switch] $OverwriteLog,
         
        [Parameter()]
        [switch] $Force
    )

    Begin
    {
        InitUploadLog $LogFile $OverwriteLog

        if ($PSCmdlet.ParameterSetName -eq 'file')
        {
            Log "Loading configuration from $ConfigJsonFile" $False
            $configData = Get-Content -Raw -Path $ConfigJsonFile | ConvertFrom-Json
            Log "Configuration: $configData" $False
            $CustomerId = $configData.CustomerId
            $CloudPlatform = $configData.CloudPlatform
            $SmbHost = $configData.UploadSmb.Host
            $SmbPort = $configData.UploadSmb.Port
            $SmbShare = $configData.UploadSmb.Share
            $SmbPath = $configData.UploadSmb.Path
            $SmbDiskName = $configData.UploadSmb.DiskName
            $SmbUserDomain = $configData.UploadSmb.UserDomain
            $SmbUserName = $configData.UploadSmb.UserName
            $AzureSubscriptionId = $configData.AzureSubscriptionId
            $AzureLocation = $configData.AzureLocation
            $TargetResourceGroup = $configData.TargetResourceGroup
            $CloudDiskName = $configData.CloudDiskName
            if (-not [String]::IsNullOrWhiteSpace($configData.UploadSmb.DiskFormat))
            {
                $SmbDiskFormat = $configData.UploadSmb.DiskFormat
            }
            if ($null -ne $configData.UploadTimeout -and $configData.UploadTimeout -gt 0)
            {
                $UploadTimeout = [int]$configData.UploadTimeout
            }
            if (-not [String]::IsNullOrWhiteSpace($configData.AzureStorageType))
            {
                $AzureStorageType = $configData.AzureStorageType
            }
            if ($Threads -le 0 -and -not [String]::IsNullOrWhiteSpace($configData.Threads))
            {
                $Threads = [int]$configData.Threads
            }
            if ([String]::IsNullOrWhiteSpace($GcpServiceAccountKeyFile))
            {
                $GcpServiceAccountKeyFile = $configData.GcpServiceAccountKeyFile
            }
        }

        $smbConfig = InitSmbConfig $SmbHost $SmbPort $SmbShare $SmbPath $SmbUserDomain $SmbUserName $SmbDiskName $SmbDiskFormat
        if ($null -eq $SmbCred)
        {
            Log "Generating SMB credential using username and password" $False
            $password = Read-Host -assecurestring "SMB user password"
            $smbConfig.SmbCred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $smbConfig.UserAndDomain, $password
        }
        else
        {
            Log "SMB credential given as input" $False
            $smbConfig.SmbCred = $SmbCred
        }
    }

    Process
    {
        try
        {
            if ($CloudPlatform -eq "Azure")
            {
                Log "Loading required modules" $False
                LoadModules @('Az.Accounts', 'Az.Compute') $Install
                AuthAzure $AzureClientId $AzureSecret $AzureTenantId $AzureSubscriptionId
                if ($Force)
                {
                    CleanUpAzureDisk $CloudDiskName $TargetResourceGroup
                }
                elseif ($null -ne (GetAzureDisk $CloudDiskName $TargetResourceGroup))
                {
                    $msg = "Managed disk '$CloudDiskName' aleady exists in resource group $TargetResourceGroup. Consider using the -Force option"
                    ThrowError ([UploaderException]::new($msg))
                }
                $fileSize = GetVhdSizeOnSmbShare $smbConfig
                $diskUrlWithSas = CreateManagedDisk -sizeInBytes $fileSize -uploadTimeout $UploadTimeout -azureStorageType $AzureStorageType -azureLocation $AzureLocation -targetResourceGroup $TargetResourceGroup -cloudDiskName $CloudDiskName
                $InformationPreference = "Continue"
                try
                {
                    UploadFromSmbToAzure $diskUrlWithSas $smbConfig $Threads $TargetResourceGroup $CloudDiskName $Global:UploadLogFile
                }
                catch
                {
                    ThrowError ([UploaderException]::new("Failed to copy disk to Azure", $_.Exception))
                }
            }

            if ($CloudPlatform -eq "Aws")
            {
                $InformationPreference = "Continue"
                try
                {
                    return UploadFromSmbToAws $smbConfig $AwsProfileName $AwsRegion $null $null $Threads $Global:UploadLogFile
                }
                catch
                {
                    ThrowError ([UploaderException]::new("Failed to copy disk to AWS", $_.Exception))
                }
            }

            if ($CloudPlatform -eq "Gcp")
            {
                # Temporary solution to redirecting 1.48 assemblies that are compile time referenced in the Google.Api.Gax.Rest assemblies
                # to the 1.49 assemblies that the other Google assemblies reference
                # This is the powershell way to accomplish Binding Redirects, that are typically done in the configuration file.
                $modulePath = (Get-Item (Get-Module -Name Citrix.Image.Uploader).Path).DirectoryName
                $GoogleApis = [reflection.assembly]::LoadFrom($modulePath + "\bin\netstandard2.0\Google.Apis.dll")
                $GoogleApisCore = [reflection.assembly]::LoadFrom($modulePath + "\bin\netstandard2.0\Google.Apis.Core.dll")
                $GoogleApisAuth = [reflection.assembly]::LoadFrom($modulePath + "\bin\netstandard2.0\Google.Apis.Auth.dll")
                $OnAssemblyResolve = [System.ResolveEventHandler] {
                    param($s, $e)
                    Log "Resolving assembly '$($e.Name)'" $False
                    if (($e.Name.StartsWith("Google.Apis.Core, Version=1.49.0.0")) -or
                        ($e.Name.StartsWith("Google.Apis.Auth, Version=1.49.0.0")) -or
                        ($e.Name.StartsWith("Google.Apis, Version=1.49.0.0")))
                    {
                        Log ("This workaround may no longer be necessary. The Google assemblies may now be referencing the correct assemblies. " +
                             "Try removing this event handler (OnAssemblyResolve) and try again.") $False
                    }
                    if ($e.Name -eq "Google.Apis.Core, Version=1.48.0.0, Culture=neutral, PublicKeyToken=4b01fa6e34db77ab")
                    {
                        Log ("Forcing the Assembly '$($e.Name)' to be '$($GoogleApisCore.GetName().Name), " +
                             "Version=$($GoogleApisCore.GetName().Version)'") $False
                        return $GoogleApisCore
                    }
                    if ($e.Name -eq "Google.Apis.Auth, Version=1.48.0.0, Culture=neutral, PublicKeyToken=4b01fa6e34db77ab")
                    {
                        Log ("Forcing the Assembly '$($e.Name)' to be '$($GoogleApisAuth.GetName().Name), " +
                             "Version=$($GoogleApisAuth.GetName().Version)'") $False
                        return $GoogleApisAuth
                    }
                    if ($e.Name -eq "Google.Apis, Version=1.48.0.0, Culture=neutral, PublicKeyToken=4b01fa6e34db77ab")
                    {
                        Log "Forcing the Assembly '$($e.Name)' to be '$($GoogleApis.GetName().Name), Version=$($GoogleApis.GetName().Version)'" $False
                        return $GoogleApis
                    }
                    foreach($a in [System.AppDomain]::CurrentDomain.GetAssemblies())
                    {
                        if ($a.FullName -eq $e.Name)
                        {
                            return $a
                        }
                    }
                    return $null
                }
                Log "Registering AssemblyResolve event handling" $False
                [System.AppDomain]::CurrentDomain.add_AssemblyResolve($OnAssemblyResolve)

                if ($Force)
                {
                    CleanUpGcpDisk $GcpServiceAccountKeyFile $CloudDiskName
                }
                $InformationPreference = "Continue"
                try
                {
                    UploadFromSmbToGcp $CloudDiskName $GcpServiceAccountKeyFile $smbConfig $Global:UploadLogFile
                }
                catch [System.Reflection.ReflectionTypeLoadException]
                {
                    Log "Message: $($_.Exception.Message)" $False
                    Log "StackTrace: $($_.Exception.StackTrace)" $False
                    Log "LoaderExceptions: $($_.Exception.LoaderExceptions)" $False
                    try
                    {
                        Log "Redirected Google SDK package version and retry uploading process"
                        UploadFromSmbToGcp $CloudDiskName $GcpServiceAccountKeyFile $smbConfig $Global:UploadLogFile
                    }
                    catch
                    {
                        ThrowError ([UploaderException]::new("Failed to copy disk to Google Cloud", $_.Exception))
                    }
                }
                catch
                {
                    ThrowError ([UploaderException]::new("Failed to copy disk to Google Cloud", $_.Exception))
                }
            }
        }
        catch [UploaderException]
        {
            LogIfSslError $_
            $PSCmdlet.ThrowTerminatingError($_)
        }
        catch
        {
            Log $_
            LogIfSslError $_
            $PSCmdlet.ThrowTerminatingError($_)
        }
    }
}

Function InitSmbConfig([string]$smbHost, [string]$smbPort, [string]$smbShare, [string]$smbPath, [string]$smbUserDomain, [string]$smbUserName,
                       [string]$smbDiskName, [string]$smbDiskFormat)
{
    $DISK_FORMATS = @{
        VhdDiskFormat = "vhd"
        VhdxDiskFormat = "vhdx"
        VmdkDiskFormat = "vmdk"
        VmdkSparseDiskFormat = "vmdk"
        QCow2DiskFormat = "qcow"
        RawDiskFormat = "raw"
    }
    $smbConfig = @{}
    $smbConfig.DiskExtension = $DISK_FORMATS[$smbDiskFormat]
    $smbConfig.UserAndDomain = "$($smbUserDomain)\$($smbUserName)"

    if ($smbPort)
    {
        $smbConfig.ShareUnc = "\\$($smbHost):$($smbPort)\$($smbShare)"
    }
    else
    {
        $smbConfig.ShareUnc = "\\$($smbHost)\$($smbShare)"
    }
    if ($smbPath)
    {
        $smbConfig.ExportFilePath = Join-Path -Path $smbPath -ChildPath "$($smbDiskName).$($smbConfig.DiskExtension)"
    }
    else
    {
        $smbConfig.ExportFilePath = "$($smbDiskName).$($smbConfig.DiskExtension)"
    }
    $smbConfig.FileOnShare = Join-Path -Path $smbConfig.ShareUnc -ChildPath $smbConfig.ExportFilePath
    return $smbConfig
}

Function GetVhdSizeOnSmbShare([psobject]$smbConfig)
{
    try
    {
        Log "Getting VHD size as $($smbConfig.UserAndDomain) for $($smbConfig.FileOnShare)" $False
        $getVhdSize = {
            param($SharePath, $Arguments)
            $fullPath = Join-Path -Path $SharePath -ChildPath $Arguments[0]
            return Get-VhdSize -File $fullPath -RoundUp -IncludeFooterSize
        }
        $fileSize = ExecuteOnSmbShare $getVhdSize $smbConfig.SmbCred $smbConfig.ShareUnc @($smbConfig.ExportFilePath)
        Log "VHD size for $($smbConfig.FileOnShare) is $fileSize"
        return $fileSize
    }
    catch
    {
        ThrowError ([UploaderException]::new("Failed to get VHD size for $($smbConfig.FileOnShare)", $_))
    }
}

Function UploadFromSmbToAzure([string]$destination, [psobject]$smbConfig, [int]$threads, [string]$targetResourceGroup, [string]$cloudDiskName,
                              [string]$logFileName)
{
    $uploadScript = {
        CopyWithCloudUploader $destination $smbConfig $threads $logFileName
    }
    DoAzureUpload $uploadScript $smbConfig.FileOnShare $threads $targetResourceGroup $cloudDiskName
}

Function CopyWithCloudUploader([string]$destination, [psobject]$smbConfig, [int]$threads, [string]$logFileName)
{
    $cloudupload = {
        param($SharePath, $Arguments)
        $fullPath = Join-Path -Path $SharePath -ChildPath $Arguments[0]
        Copy-ToAzDisk -File $fullPath -Sas $destination -Threads $threads -LogFileName $logFileName
    }
    ExecuteOnSmbShare $cloudupload $smbConfig.SmbCred $smbConfig.ShareUnc @($smbConfig.ExportFilePath)
}

Function UploadFromSmbToAws([psobject]$smbConfig, [string]$awsProfileName, [string]$awsRegion, [string]$description, [HashTable]$tags, [int]$threads,
                            [string]$logFileName)
{
    Log ("Copying disk '$($smbConfig.FileOnShare)' to AWS " + $(if ($threads -le 0) {"(threads=default)"} else {"(threads=$threads)"}))
    $cloudupload = {
        param($SharePath, $Arguments)
        $fullPath = Join-Path -Path $SharePath -ChildPath $Arguments[0]
        $copyArgs = GetAwsCopyArgs $fullPath $awsProfileName $awsRegion $description $tags $threads $logFileName
        Copy-ToAwsDisk @copyArgs
    }
    $snapshotId = ExecuteOnSmbShare $cloudupload $smbConfig.SmbCred $smbConfig.ShareUnc @($smbConfig.ExportFilePath)
    Log "Copied disk to AWS snapshot $snapshotId"
    return $snapshotId
}

Function UploadFromSmbToGcp([string]$cloudDiskName, [string]$gcpServiceAccountKeyFile, [psobject]$smbConfig, [string]$logFileName)
{
    $bucketName = DeriveBucketName $cloudDiskName
    Log "Copying disk '$($smbConfig.FileOnShare)' to bucket '$bucketName'"
    $cloudupload = {
        param($SharePath, $Arguments)
        $fullPath = Join-Path -Path $SharePath -ChildPath $Arguments[0]
        Copy-ToGcpDisk -File $fullPath -BucketName $bucketName -ServiceAccountKeyFile $gcpServiceAccountKeyFile -LogFileName $logFileName
    }
    ExecuteOnSmbShare $cloudupload $smbConfig.SmbCred $smbConfig.ShareUnc @($smbConfig.ExportFilePath)
    Log "Copied disk to image '$cloudDiskName' via bucket '$bucketName'"
}

Function ExecuteOnSmbShare([ScriptBlock]$scriptblock, [PSCredential]$smbCred, [string]$share, [string[]]$arguments)
{
    $name = "CtxMapping"
    Log "ExecuteOnSmbShareWithCreds on share $share with args $arguments" $False
    $err = ""
    $drive = New-PSDrive -Name $name -PSProvider "FileSystem" -Root $share -Credential $smbCred -Scope Script -ErrorAction SilentlyContinue `
      -ErrorVariable err
    if ($null -eq $drive)
    {
        ThrowError ([UploaderException]::new("Failed to map share $share", $_.Exception))
    }
    try
    {
        Log "Operating on $($drive.Name) with $arguments" $False
        $output = & $scriptblock -SharePath "$($name):" -Arguments $arguments
        Log "Upload successful" $False
    }
    finally
    {
        try
        {
            $null = Remove-PSDrive -Name $name -Force
        }
        catch
        {
            Log "Failure removing PS drive $($name). $_" $False
        }
    }
    return $output
}