Private/Invoke-StorageUpload.ps1

<#
.Synopsis
Uploads content to Azure Blob Storage using Az.Storage module
 
Created on: 28/12/2024
Updated on: 01/01/2025
Created by: Ben Whitmore
Filename: Invoke-StorageUpload.ps1
 
.Description
Uses Az.Storage module to upload content to Azure Blob Storage with retry logic and progress tracking
 
.PARAMETER Uri
The Azure Storage SAS URI for upload
 
.PARAMETER FilePath
Path to the file to upload
 
.PARAMETER FileSize
The size of the encrypted file
 
.PARAMETER ContentVersion
The content version ID
 
.PARAMETER ContentRequestId
 
.PARAMETER ContentRequest
The content request object
 
.PARAMETER Win32AppId
The ID of the Win32 app
 
.PARAMETER RetryCount
Number of retry attempts (default: 3)
 
.PARAMETER RetryDelay
Seconds between retries (default: 5)
 
.PARAMETER LogId
Component name for logging
#>

function Invoke-StorageUpload {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0, HelpMessage = 'The Azure Storage SAS URI for upload')]
        [string]$Uri,

        [Parameter(Mandatory = $true, Position = 1, HelpMessage = 'Path to the file to upload')]
        [string]$FilePath,

        [Parameter(Mandatory = $true, Position = 2, HelpMessage = 'The size of the encrypted file')]
        [string]$FileSize,

        [Parameter(Mandatory = $true, Position = 3, HelpMessage = 'The content version ID')]
        [string]$ContentVersion,

        [Parameter(Mandatory = $true, Position = 4, HelpMessage = 'The ID of the content request')]
        [string]$ContentRequestId,

        [Parameter(Mandatory = $true, Position = 5, HelpMessage = 'The ID of the content request')]
        [object]$ContentRequest,

        [Parameter(Mandatory = $true, Position = 6, HelpMessage = 'The ID of the Win32 app')]
        [string]$Win32AppId,

        [Parameter(Mandatory = $false, Position = 7, HelpMessage = 'Number of retry attempts (default: 10)')]
        [int]$RetryCount = 10,

        [Parameter(Mandatory = $false, Position = 8, HelpMessage = 'Seconds between retries (default: 5)')]
        [int]$RetryDelay = 5,

        [Parameter(Mandatory = $false, HelpMessage = 'Component name for logging')]
        [string]$LogId = $($MyInvocation.MyCommand).Name
    )

    begin {

        Write-LogAndHost -Message "Function: Invoke-StorageUpload was called" -LogId $LogId -ForegroundColor Cyan

        # Check for required module
        Initialize-Module -Modules @('Az.Storage')

        try {

            $sasUri = [System.Uri]::new($Uri)
            
            # Get container (second segment)
            $container = $sasUri.AbsolutePath.Split('/')[1]

            # Get full blob path (all segments after container)
            $blobPath = $sasUri.AbsolutePath.Substring($container.Length + 2)

            # Get SAS token
            $sasToken = $sasUri.Query.TrimStart('?')

            # LLog the results
            $uploadInfo = [PSCustomObject]@{
                Container = $container
                BlobPath  = $blobPath
                SasToken  = $sasToken
            }

            Write-Log -Message ($uploadInfo | ConvertTo-Json -Compress) -LogId $LogId
            Write-Host ($uploadInfo | ConvertTo-Json -Compress) -ForegroundColor Green
        }
        catch {
            Write-LogAndHost -Message ("Error parsing Sas Uri: {0}" -f $_.Exception.Message) -LogId $LogId -Severity 3

            throw
        }
    }

    process {

        try {

            # Upload content file information to Win32 app
            Write-LogAndHost -Message ("Adding Content information to '{0}'" -f $Win32AppId) -LogId $LogId -ForegroundColor Cyan     
            Write-LogAndHost -Message ("Starting upload of '{0}' to Azure Storage using Set-AzStorageBlobContent" -f $FilePath) -LogId $LogId -ForegroundColor Cyan

            $attempt = 1
            $success = $false

            # Initialize block size (e.g., 4 MB)
            $blockSize = 4 * 1024 * 1024

            $fileSize = (Get-Item $FilePath).Length
            $blocks = [Math]::Ceiling($fileSize / $blockSize)

            do {
                try {
                    # Create a context for the storage account
                    $context = New-AzStorageContext -SasToken $sasToken -StorageAccountName $sasUri.Host.Split('.')[0]
                    $blobClient = [Microsoft.Azure.Storage.Blob.CloudBlockBlob]::new($sasUri)

                    # Initialize upload parameters
                    $fileStream = [System.IO.File]::OpenRead($FilePath)
                    $buffer = New-Object Byte[] $blockSize
                    $blockIds = New-Object 'System.Collections.Generic.List[System.String]'

                    Write-LogAndHost -Message ("Uploading file in chunks of {0} bytes" -f $blockSize) -LogId $LogId -ForegroundColor Cyan

                    try {
                        $i = 0
                        while (($bytesRead = $fileStream.Read($buffer, 0, $buffer.Length)) -gt 0) {
                            $blockId = [Guid]::NewGuid().ToString()
                            $encodedBlockId = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($blockId))
                            $blockIds.Add($encodedBlockId)

                            # Create a memory stream for the current chunk
                            $memoryStream = New-Object System.IO.MemoryStream
                            $memoryStream.Write($buffer, 0, $bytesRead)
                            $memoryStream.Position = 0

                            # Upload the chunk
                            $blobClient.PutBlock($encodedBlockId, $memoryStream, $null)

                            # Dispose of the memory stream
                            $memoryStream.Dispose()
                            $i++
                            Write-LogAndHost -Message ("Uploading block {0} of {1}" -f $i, $blocks) -LogId $LogId -ForegroundColor Cyan
                        }

                        # Commit the blocks
                        $blobClient.PutBlockList($blockIds)
                        Write-LogAndHost -Message "Upload reported as completed successfully" -LogId $LogId -ForegroundColor Green
                        $success = $true
                    }
                    finally {
                        $fileStream.Dispose()
                    }
                }
                catch {
                    if ($attempt -ge $RetryCount) {
                        Write-LogAndHost -Message ("Upload failed after {0} attempts: {1}" -f $RetryCount, $_.Exception.Message) -LogId $LogId -Severity 3
                        
                        throw
                    }

                    Write-LogAndHost -Message ("Attempt {0}/{1} failed. Retrying... Error: {2}" -f $attempt, $RetryCount, $_.Exception.Message) -LogId $LogId -Severity 2
                    Start-Sleep -Seconds $RetryDelay
                }
                $attempt++
            } while (-not $success -and $attempt -le $RetryCount)
            
            
            
            # Verify upload success
            if ($PSVersionTable.PSVersion.Major -ge 7) {

                # Verify upload success
                Write-LogAndHost -Message "We have a PowerShell 7+ session! We can attempt to verify the upload success..." -LogId $LogId -ForegroundColor Cyan
            
                # Delay to allow Azure to update blob properties
                Start-Sleep -Seconds $RetryDelay
                $blob = Get-AzStorageBlob -Context $context -Container $container -Blob $blobPath

                if ($blob) {
                    $blobInfo = [PSCustomObject]@{
                        Name         = $blob.Name
                        BlobType     = $blob.BlobType
                        Length       = $blob.Length
                        Uri          = $blob.ICloudBlob.Uri.AbsoluteUri
                        LastModified = $blob.ICloudBlob.Properties.LastModified
                        ContentType  = $blob.ICloudBlob.Properties.ContentType
                    }
                    $json = $blobInfo | ConvertTo-Json -Compress
                    Write-LogAndHost -Message ("Blob info: {0}" -f $json) -LogId $LogId -ForegroundColor Green
                }
                else {
                    Write-LogAndHost -Message "Blob not found in the specified container and path." -LogId $LogId -Severity 3

                    throw
                }

                $attempt = 1
                $success = $false

                do {
                    try {

                        # Get blob size and compare
                        if ($blob) {
                            $blob.ICloudBlob.FetchAttributes()
                            $FileSize = (Get-Item $FilePath).Length
                            $blobSize = $blob.ICloudBlob.Properties.Length
            
                            Write-LogAndHost -Message ("Comparing file size: Local file size is {0} bytes, Blob file size is {1} bytes" -f $FileSize, $blobSize) -LogId $LogId -ForegroundColor Cyan
            
                            if ($FileSize -eq $blobSize) {
                                Write-LogAndHost -Message "Upload verification successful: File sizes match" -LogId $LogId -ForegroundColor Green
                                $success = $true
                            }
                            else {

                                # Upload verification failed, sizes do not match
                                Write-LogAndHost -Message "Upload verification failed: File sizes do not match" -LogId $LogId -Severity 3

                                break
                            }
                        }
                        else {

                            # Blob not found
                            Write-LogAndHost -Message "Blob not found in the specified container and path." -LogId $LogId -Severity 3

                            break
                        } 
                    }
                    catch {
                        if ($attempt -ge $RetryCount) {
                            Write-LogAndHost -Message ("Upload verification failed after {0} attempts: {1}" -f $RetryCount, $_.Exception.Message) -LogId $LogId -Severity 3

                            throw
                        }
            
                        Write-LogAndHost -Message ("Attempt {0}/{1} failed. Retrying..." -f $attempt, $RetryCount) -LogId $LogId -Severity 2
                        Start-Sleep -Seconds $RetryDelay
                        $attempt++
                    }
                } while (-not $success -and $attempt -le $RetryCount)
            }
            else {
                Write-LogAndHost -Message "Skipping upload verification because PowerShell version is less than 7" -LogId $LogId -ForegroundColor Cyan
                
                # Assume success because we can't verify withour PowerShell 7+ for the Get-AzStorageBlob cmdlet
                $success = $true
            }
        }
        catch {
            Write-LogAndHost -Message ("Upload failed: {0}" -f $_.Exception.Message) -LogId $LogId -Severity 3

            throw
        }

        if ($success -eq $true) {

            # Verify upload state
            Write-LogAndHost -Message "Verifying upload state..." -LogId $LogId -ForegroundColor Cyan

            $attempt = 1
            $success = $false

            do {
                try {

                    # Construct the status URI
                    $statusUri = "deviceAppManagement/mobileApps/{0}/microsoft.graph.win32LobApp/contentVersions/{1}/files/{2}" -f $Win32AppId, $ContentVersion, $ContentRequestId

                    # Make the GET request to check upload state
                    $statusResponse = Invoke-MgGraphRequestCustom -Method GET -Resource $statusUri

                    # Check if the upload state is acceptable
                    if (($statusResponse.uploadState) -eq "azureStorageUriRequestSuccess" ) {
                        Write-LogAndHost -Message ("Upload state is '{0}'." -f ($statusResponse.uploadState)) -LogId $LogId -ForegroundColor Green
                        $success = $true
                    }
                    else {
                        Write-LogAndHost -Message ("Upload state is '{0}'. Waiting..." -f ($statusResponse.uploadState)) -LogId $LogId -Severity 2
                        Start-Sleep -Seconds $RetryDelay
                    }
                }
                catch {

                    # Log error and decide whether to retry
                    Write-LogAndHost -Message ("Attempt {0}/{1} failed. Error: {2}" -f $attempt, $RetryCount, $_.Exception.Message) -LogId $LogId -Severity 3
                    Write-LogAndHost -Message ("Attempt {0}/{1} failed. Retrying in {2} seconds." -f $attempt, $RetryCount, $RetryDelay) -LogId $LogId -Severity 2
                    Start-Sleep -Seconds $RetryDelay
                }

                # Increment attempt counter only after each iteration
                $attempt++

            } while (-not $success -and $attempt -le $RetryCount)

            if ($success -eq $true) {
                Write-LogAndHost -Message "Upload completed successfully" -LogId $LogId -ForegroundColor Green

                return $true
            }
            else {
                Write-LogAndHost -Message "Upload verification failed" -LogId $LogId -Severity 3

                return $false
            }
        }
    }
}