Copy-Disk.ps1

<#
 .Synopsis
  Implement a cloud disk upload job

 .Description
  Implement a cloud disk upload job. This function supports copy disk from smb share
  server to multiple platform (Azure and GCP)
#>

Function Copy-Disk
{
    [CmdletBinding(DefaultParameterSetName = 'cmd')]
    Param(
        [Parameter(Mandatory = $true, ParameterSetName = 'file')]
        [string] $ConfigJsonFile,
        [Parameter(Mandatory = $false, ParameterSetName = 'cmd')]
        [string] $CustomerId,
        [Parameter(Mandatory = $true, ParameterSetName = 'cmd')]
        [string] $CloudPlatform,
        [Parameter(Mandatory = $true, ParameterSetName = 'cmd')]
        [string] $SmbHost,
        [Parameter(Mandatory = $false, ParameterSetName = 'cmd')]
        [string] $SmbPort = $null,
        [Parameter(Mandatory = $true, ParameterSetName = 'cmd')]
        [string] $SmbShare,
        [Parameter(Mandatory = $false, ParameterSetName = 'cmd')]
        [string] $SmbPath,
        [Parameter(Mandatory = $true, ParameterSetName = 'cmd')]
        [string] $SmbDiskName,
        [Parameter(Mandatory = $false, ParameterSetName = 'cmd')]
        [string] $SmbDiskFormat = "VhdDiskFormat",
        [Parameter(Mandatory = $true, ParameterSetName = 'cmd')]
        [string] $SmbUserDomain,
        [Parameter(Mandatory = $true, ParameterSetName = 'cmd')]
        [string] $SmbUserName,
        [Parameter(Mandatory = $false, ParameterSetName = 'cmd')]
        [string] $AzureSubscriptionId,
        [Parameter(Mandatory = $false, ParameterSetName = 'cmd')]
        [string] $AzureLocation,
        [Parameter(Mandatory = $false, ParameterSetName = 'cmd')]
        [string] $TargetResourceGroup,
        [Parameter(Mandatory = $false, ParameterSetName = 'cmd')]
        [string] $CloudDiskName,
        [Parameter(Mandatory = $false, ParameterSetName = 'cmd')]
        [int] $UploadTimeout = 36000,
        [Parameter(Mandatory = $false, ParameterSetName = 'cmd')]
        [string] $AzureStorageType = "Premium_LRS",
        [Parameter(Mandatory = $false, ParameterSetName = 'cmd')]
        [int] $Threads = 5,
        [Parameter(Mandatory = $false)]
        [string] $AzureClientId,
        [Parameter(Mandatory = $false)]
        [string] $AzureSecret,
        [Parameter(Mandatory = $false)]
        [string] $AzureTenantId,
        [Parameter(Mandatory = $false)]
        [string] $GcpServiceAccountKeyFile,
        [Parameter(Mandatory = $false)]
        [pscredential] $SmbCred,
        [Parameter(Mandatory = $false)]
        [switch] $Install,
        [Parameter(Mandatory = $false)]
        [switch] $OverwriteLog,
        [Parameter(Mandatory = $false)]
        [switch] $Force
    )

    Begin
    {
        InitUploadLog $OverwriteLog
        if ($PSCmdlet.ParameterSetName -eq 'file') {
            Log "Loading config from $ConfigJsonFile"
            $configData = Get-Content -Raw -Path $ConfigJsonFile | ConvertFrom-Json
            Log "Config: $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
            $SmbDiskFormat = $configData.UploadSmb.DiskFormat
            if([String]::IsNullOrWhiteSpace($SmbDiskFormat)) { $SmbDiskFormat = "VhdDiskFormat" }
            $SmbUserDomain = $configData.UploadSmb.UserDomain
            $SmbUserName = $configData.UploadSmb.UserName
            $AzureSubscriptionId = $configData.AzureSubscriptionId
            $AzureLocation = $configData.AzureLocation
            $TargetResourceGroup = $configData.TargetResourceGroup
            $CloudDiskName = $configData.CloudDiskName
            if($configData.UploadTimeout -eq $null -Or $configData.UploadTimeout -le 0) { $UploadTimeout = 36000 }
            else { $UploadTimeout = [int]$configData.UploadTimeout }
            if([String]::IsNullOrWhiteSpace($configData.AzureStorageType)) { $AzureStorageType = "Premium_LRS" }
            else {$AzureStorageType = $configData.AzureStorageType}
            if($configData.Threads -eq $null -Or $configData.Threads -le 0) { $Threads = 5 }
            else { $Threads = [int]$configData.Threads }
        }
        Log "***** Initialize Smb Config *****"
        $smbConfig = InitSmbConfig $SmbHost $SmbPort $SmbShare $SmbPath $SmbUserDomain $SmbUserName $SmbDiskName $SmbDiskFormat
        if ($SmbCred -eq $null) {
            Log "Generating Smb Credential using username and password"
            $password = Read-Host -assecurestring "SmbUserPassword"
            $smbConfig.SmbCred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $smbConfig.UserAndDomain, $password
        } else {
            Log "Smb Credential is given as input"
            $smbConfig.SmbCred = $SmbCred
        }
    }

    Process
    {
        if ($CloudPlatform -eq "Azure")
        {
            Log "***** Load Required Modules *****"
            LoadModules @('Az.Accounts', 'Az.Compute') $Install
            Log "***** Authenticate *****"
            AuthAzure $AzureClientId $AzureSecret $AzureTenantId $AzureSubscriptionId
            if ($Force) {
                Log "***** Pre Upload Cleanup *****"
                CleanUpAzureDisk $AzureSubscriptionId $CloudDiskName $TargetResourceGroup
            }
            Log "***** Starting Upload *****"
            Log "1. Get Vhd Size"
            $fileSize = GetVhdSize $smbConfig
            Log "2. Create Managed Disk"
            $managedDiskUrlWithSas = CreateManagedDisk $fileSize $UploadTimeout $AzureSubscriptionId $AzureStorageType $AzureLocation $TargetResourceGroup $CloudDiskName
            Log "3. Uploading"
            $InformationPreference = "Continue"
            UploadFromSmbToAzure $managedDiskUrlWithSas $smbConfig $Threads $TargetResourceGroup $CloudDiskName
            Log "***** Finished Upload *****"
        }

        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."
            [System.AppDomain]::CurrentDomain.add_AssemblyResolve($OnAssemblyResolve)

            if ($Force) {
                Log "***** Pre Upload Cleanup *****"
                CleanUpGcpDisk $GcpServiceAccountKeyFile $CloudDiskName
            }
            Log "***** Starting Upload *****"
            $InformationPreference = "Continue"
            try {
                UploadFromSmbToGcp $CloudDiskName $GcpServiceAccountKeyFile $smbConfig
            } 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
                }
                catch {
                    Log "Exception encountered when uploading"
                    Log $_.Exception $false
                    ThrowException "Failed to upload disk $CloudDiskName to $CloudPlatform"
                }
            } catch {
                Log "Exception encountered when uploading"
                Log $_.Exception $false
                ThrowException "Failed to upload disk $CloudDiskName to $CloudPlatform"
            }
        }
    }
}

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 LoadModules([string[]]$modules, [bool]$installModule = $False) {
    foreach ($module in $modules) {
        if (Get-Module -ListAvailable -Name $module) {
            Log "Module $module exists"
        }
        else {
            if ($installModule) {
                Log "Installing $module"
                Install-Module -Name $module -Scope CurrentUser -AllowClobber -Force
            } else {
                ThrowException "Module $module is missing. You can either install it manually or add -Install in the cmdlet to auto-install all missing required modules"
            }
        }
    }
}

Function AuthAzure([string]$azureClientId, [string]$azureSecret, [string]$azureTenantId, [string]$azureSubscriptionId) {
    if ($azureClientId -And $azureSecret -And $azureTenantId) {
        Log "Authenticating to Azure using ServicePrincipal id $azureClientId"
        $secret = ConvertTo-SecureString $azureSecret -AsPlainText -Force
        $pscredential = New-Object -TypeName System.Management.Automation.PSCredential($azureClientId, $secret)
        $azureLoginResult = Connect-AzAccount -ServicePrincipal -Credential $pscredential -Tenant $azureTenantId
    } else {
        Log "Authenticating to Azure using interactive auth"
        $context = Get-AzContext
        if (!$context -or ($context.Subscription.Id -ne $azureSubscriptionId)) {
            $output = Connect-AzAccount -Subscription $azureSubscriptionId
            if (!$output) {
                Log "Did not authenticate with Azure" $False
                $azureLoginResult = $False
            } else {
                Log "SubscriptionId '$azureSubscriptionId' connected"
                $azureLoginResult = $True
            }
        } else {
            Log "SubscriptionId '$azureSubscriptionId' already connected"
            $azureLoginResult = $True
        }
    }
    if (!$azureLoginResult) {
        Log "Authenticated to Azure failed!"
    }
    Log "Authenticated to Azure ($azureLoginResult)"
}

Function CleanUpAzureDisk([string]$azureSubscriptionId, [string]$cloudDiskName, [string]$targetResourceGroup) {
    $err = ""
    Log "Deleting existing disk $($cloudDiskName) from Azure subscription $($azureSubscriptionId) resource group $($targetResourceGroup)"
    $azContextResult = Select-AzSubscription -SubscriptionId $azureSubscriptionId -ErrorVariable "err"
    if ($null -eq $azContextResult) {
        ThrowException "Failed to find azure subscription $azureSubscriptionId in AzContext error: $err"
    }
    Get-AzDisk -ResourceGroupName $targetResourceGroup -DiskName $cloudDiskName | ForEach-Object {
        $diskName = $_.Name
        Log "Deleting existing managed disk $diskName"
        $result = Remove-AzDisk -ResourceGroupName $targetResourceGroup -DiskName $diskName -Force -ErrorVariable "err"
        if ($null -eq $result) {
            ThrowException "Failed to delete existing managed disk $diskName error: $err"
        }
        Log "Deleted existing managed disk $diskName"
    }
}

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

Function CreateManagedDisk([long]$sizeInBytes, [int]$uploadTimeout, [string]$azureSubscriptionId,
    [string]$azureStorageType, [string]$azureLocation, [string]$targetResourceGroup, [string]$cloudDiskName) {
    $err = ""
    $sasExpiryDuration = $uploadTimeout
    $azContextResult = Select-AzSubscription -SubscriptionId $azureSubscriptionId -ErrorVariable "err"
    if ($null -eq $azContextResult) {
        ThrowException "Failed to find azure subscription $azureSubscriptionId in AzContext error: $err"
    }
    $diskConfig = New-AzDiskConfig -AccountType $azureStorageType -Location $azureLocation -UploadSizeInBytes $sizeInBytes -CreateOption 'Upload' -OsType Windows -HyperVGeneration V2
    Log "Creating managed disk $($cloudDiskName) in sub $($azureSubscriptionId) rg $($targetResourceGroup) loc $($azureLocation) with size $sizeInBytes bytes"
    $result = New-AzDisk -ResourceGroupName $targetResourceGroup -DiskName $cloudDiskName -Disk $diskConfig -ErrorVariable "err"
    if ($null -eq $result)
    {
        ThrowException "Failed to create managed disk $($cloudDiskName) in rg $($targetResourceGroup) error: $err"
    }

    Log "Granting access to managed disk $($cloudDiskName) for $($sasExpiryDuration) seconds"
    $access = Grant-AzDiskAccess -ResourceGroupName $targetResourceGroup -DiskName $cloudDiskName -DurationInSecond $sasExpiryDuration -Access 'Write' -ErrorVariable "err"
    if ($null -eq $access)
    {
        ThrowException "Failed to create sas for mananged disk $($cloudDiskName) error: $err"
    }
    $sas = $access.AccessSAS
    Log "Created managed disk $($cloudDiskName) with sas $sas"
    return $sas
}

Function UploadFromSmbToAzure([string]$destination, [psobject]$smbConfig, [int]$threads, [string]$targetResourceGroup, [string]$cloudDiskName) {
    try {
        CopyWithCloudUploader $destination $smbConfig $threads
        $disk = Get-AzDisk -ResourceGroupName $targetResourceGroup -DiskName $cloudDiskName
        Log "Created and uploaded disk $($disk.name)"
    } catch {
        Remove-AzDisk -ResourceGroupName $targetResourceGroup -DiskName $cloudDiskName -Force
        ThrowException "Disk copy failed: $_"
    } finally {
        Log "Revoking Azure Disk Access"
        $revoke = Revoke-AzDiskAccess -ResourceGroupName $targetResourceGroup -DiskName $cloudDiskName
        Log "Revoke-AzDiskAccess status: $($revoke.status)"
    }
}

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

Function CleanUpGcpDisk([string]$gcpServiceAccountKeyFile, [string]$cloudDiskName) {
    $gcpServiceAccountKey = Get-Content -Raw -Path $gcpServiceAccountKeyFile | ConvertFrom-Json
    try {
        $disk = Get-GceImage -Name $cloudDiskName -Project $gcpServiceAccountKey.project_id
        if ($disk) {
            Log "Deleting existing disk image $cloudDiskName from GCP $disk"
            Remove-GceImage -Name $cloudDiskName -Project $gcpServiceAccountKey.project_id
        }
    }
    catch {
        Log "No existing disk image $cloudDiskName"
    }
}
Function UploadFromSmbToGcp([string]$cloudDiskName, [string]$gcpServiceAccountKeyFile, [psobject]$smbConfig){
    $bucketName = $cloudDiskName.Split('.')[0]
    if (-not (($bucketName.Length -le 63) -and ($bucketName -cmatch '^[a-z]([-a-z0-9]*[a-z0-9])?$'))) {
        ThrowException "Invalid CloudDiskName '$cloudDiskName'. The stem must meet the requirements for Google Cloud image names. See https://cloud.google.com/compute/docs/reference/rest/v1/images."
    }
    Log "Uploading disk to GCP"
    $cloudupload = {
        param($SharePath, $Arguments)
        $fullPath = Join-Path -Path $SharePath -ChildPath $Arguments[0]
        Log "Copying '$fullPath' to '$cloudDiskName' with $gcpServiceAccountKeyFile"
        Copy-ToGcpDisk -File $fullPath -BucketName $bucketName -ServiceAccountKeyFile $gcpServiceAccountKeyFile
    }
    ExecuteOnSmbShare $cloudupload $smbConfig.SmbCred $smbConfig.ShareUnc @($smbConfig.ExportFilePath)
    Log "Copied disk to '$cloudDiskName' in bucket '$bucketName'"
}

Function ExecuteOnSmbShare([ScriptBlock]$scriptblock, [PSCredential]$smbCred, [string]$share, [string[]]$arguments)
{
    # Use this function when the code in the scriptblock is all Powershell.
    $name = "CtxMapping"
    Log "ExecuteOnSmbShareWithCreds as $smbCred on share $share with args $arguments"
    $err = ""
    $drive = New-PSDrive -Name $name -PSProvider "FileSystem" -Root $share -Credential $smbCred -Scope Global -ErrorVariable "err"
    if (!$drive) {
        ThrowException "Failed to map share $share error: $err"
    }
    try {
        Log "Operating on $($drive.Name) with $arguments"
        $output = & $scriptblock -SharePath "$($name):" -Arguments $arguments
    }
    finally {
        $null = Remove-PSDrive -Name $name
    }
    return $output
}

Function InitUploadLog([bool]$overwrite) {
    $Global:UploadLogFile = "$(Get-Location)\Upload.log"
    $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.ffffZ")
    if ($overwrite) {
        "$($timestamp): New log" | Out-File -FilePath $Global:UploadLogFile
    } else {
        "$($timestamp): New log" | Out-File -FilePath $Global:UploadLogFile -Append
    }
    Write-Host "Logging to $($Global:UploadLogFile)"
}

Function Log([string]$message, [bool]$echoToScreen = $True) {
    if ($echoToScreen) {
        Write-Host $message
    }
    $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.ffffZ")
    "$($timestamp): $message" | Out-File -FilePath $Global:UploadLogFile -Append
}

Function ThrowException([string]$message, [bool]$echoToScreen = $True) {
    Log $message $echoToScreen
    throw $message
}