UploadWinfield.ps1

# Copyright (c) Microsoft Corporation. All rights reserved.
# UploadWinfield.ps1 1.0.0 2023-04-27 18:34:10
# ASZ-ArcA-Deploy users/sarathys/addtiming Debug-x64

 param
 (
     [Parameter(Mandatory=$true)]
     $arcaBaseFolder
 )
 
 #Requires -RunAsAdministrator
 $ErrorActionPreference = "Stop"
 
 function Trace-Execution([string] $message)
 {
     $caller = (Get-PSCallStack)[1]
     Write-Host "[$(([DateTime]::UtcNow).ToString())][$($caller.Command)] $message"
 }
 
 function DownloadWithRetry([string] $url, [string] $downloadLocation, [int] $retries)
 {
     while($true)
     {
         try
         {
             Invoke-WebRequest $url -OutFile $downloadLocation -Verbose
             break
         }
         catch
         {
             $exceptionMessage = $_.Exception.Message
             Trace-Execution "Failed to download '$url': $exceptionMessage"
             if ($retries -gt 0) {
                 $retries--
                 Trace-Execution "Waiting 10 seconds before retrying. Retries left: $retries"
                 Start-Sleep -Seconds 10
             }
             else
             {
                 $exception = $_.Exception
                 throw $exception
             }
         }
     }
 }
 
 
 function DownloadAzCopy
 {
     param 
     (
         $DownloadURL = "https://aka.ms/downloadazcopy-v10-windows",
         $OutputPath = "C:\AzCopy\"
     )
 
     Trace-Execution "START: Installing AzCopy"
     $azCopyExe = Join-Path -Path $OutputPath -ChildPath "azcopy.exe"
     
     if(Test-Path $azCopyExe)
     {
         Trace-Execution "Azcopy is already installed"
     }
     else 
     {
         Remove-Item -Path $OutputPath -Recurse -Force -ErrorAction SilentlyContinue
         New-Item -Path $OutputPath -ItemType Directory -Force -Verbose | Out-Null
         $azcopyZipFile = Join-Path -Path $OutputPath -ChildPath "AzCopy.zip"
     
         # Download AzCopy and extract the zip file
         Trace-Execution "Downloading Azcopy.exe..."
         DownloadWithRetry -url $DownloadURL -downloadLocation $azcopyZipFile -retries 6
         Expand-Archive -Path $azcopyZipFile -DestinationPath $OutputPath -Force | Out-Null
     
         # Rename the AzCopy executable to "AzCopy.exe"
         $azCopyExeExtracted = Get-ChildItem -Path $OutputPath -Filter "azcopy.exe" -Recurse
         Move-Item -Path $azCopyExeExtracted.FullName -Destination $OutputPath | Out-Null
    
         if(-not (Test-Path $azCopyExe)) 
         {
             throw "$OutputPath\azcopy.exe not found."
         }
     }
     Trace-Execution "END: Installing AzCopy"
     return $azCopyExe
 }
 
 # check pre-requisites
 function Test-Prereqs
 {
     Trace-Execution "Checking if AZURE_DEVOPS_EXT_PAT and SAS_TOKEN environment variables are set."
     $pat = [System.Environment]::GetEnvironmentVariable('AZURE_DEVOPS_EXT_PAT')
     if([string]::IsNullOrEmpty($pat))
     {
         throw "ADO PAT is not set, exiting"
     }
 
     $Global:code = [System.Environment]::GetEnvironmentVariable('SAS_TOKEN')
     if([string]::IsNullOrEmpty($code))
     {
         throw "SAS_TOKEN environment variable isn't set."
     }
 
     Trace-Execution "Pre-req check passed."
     return $true
 }
 
 # Install CLI and AzCopy.exe
 function Init
 {
     Trace-Execution "[START] Init"
     $installed = Get-Command "az.cmd" -ErrorAction "SilentlyContinue"
     if (-not $installed)
     {
         Trace-Execution "Install/Update Azure-CLI"
         DownloadWithRetry -url "https://aka.ms/installazurecliwindows" -downloadLocation ".\AzureCLI.msi" -retries 6
         Start-Process msiexec.exe -Wait -ArgumentList '/I AzureCLI.msi /quiet'
         $env:Path += ";c:\Program Files (x86)\Microsoft SDKs\Azure\CLI2\wbin"
         az extension add --name azure-devops
     }
 
     $Global:azCopyExe = DownloadAzCopy
     Trace-Execution "Azcopy installed at $azCopyExe"
     Trace-Execution "[END] Init"
 }
 
 
 # HEAD request on the blob to check if it exists
 function Test-BlobPath
 {
     [CmdletBinding()]
     param (
         [Parameter(Mandatory=$true)]
         [string]
         $Path
     )

    Trace-Execution "Test-BlobPath $Path"
    $blobUrlToCheck = "$Path$code"
    $blobUrlToCheck = $blobUrlToCheck.Replace('"','')
    $found = $false
    try 
    {
        $response = Invoke-WebRequest -Uri $blobUrlToCheck -Method Head -TimeoutSec 30     
        $responseStatusCode = [int] $response.StatusCode
    }
    catch 
    {
        Trace-Execution "Error calling $Path $($_)"
        if($null -ne $_.Exception.Response)
        {
            $responseStatusCode = [int] $_.Exception.Response.StatusCode
        }
    }

    Trace-Execution "HEAD $Path = http/$responseStatusCode"

    if($responseStatusCode -eq 200)
    {
        $found = $true
    }

    return $found
 }
 
 
 function New-BlobContainer 
 {
     param 
     (
         [string] $containerName
     )
 
     $newBlobContainerUrl = "$blobUrl/$containerName$code"
     Invoke-AzCopy -operation "create new blob container: $containerName" -azCopyParameters @("make", $newBlobContainerUrl)
 }
 
 function Get-BlobChildItem
 {
     param
     (
         [string] $containerName,
         [string] $pattern = "."
     )
 
     $containerUrl = "$blobUrl/$containerName$code"
     $blobFiles = @()
     Trace-Execution "list $blobUrl/$containerName"
     $output = Invoke-AzCopy -operation "list $blobUrl/$containerName" -azCopyParameters @("list", $containerUrl)
     $matchInfo = $output | Select-String -Pattern $pattern
     foreach($match in $matchInfo) 
     {
        $blobFiles += $match.Line.Split(";")[0].Split(":")[1].Trim()
     }
     Trace-Execution "Following files are located at $blobUrl/$containerName`:`r`n $($blobFiles | Out-String)"
     return $blobFiles
 }
 
 function Invoke-AzCopy
  {
      [CmdletBinding()]
      param
      (
          [string] $operation,
          [string[]] $azCopyParameters
      )
  
      Trace-Execution "[START][$operation]"
      try
      {
          if($azCopyParameters.Length -lt 2)
          {
              throw "expected atleast 2 azcopy parameters"
          }
  
          $azCopyCommand = $azCopyParameters[0]
          #$azCopyCmdParameters = $azCopyParameters[1..$azCopyParameters.Length] -join " "
  
          #Trace-Execution "Executing $azCopyExe $azCopyCommand $azCopyCmdParameters"
          #$output = & $azCopyExe $azCopyCommand $azCopyCmdParameters
 
          Trace-Execution "Executing $azCopyExe $azCopyCommand"
          Start-Process -FilePath $azCopyExe -ArgumentList $azCopyParameters -RedirectStandardOutput "output.txt" -Wait -NoNewWindow
          $output = Get-Content -Path "output.txt"
          Trace-Execution "azcopy output: $output"
      }
      catch 
      {
          if($ErrorActionPreference -eq 'Continue')
          {
              Trace-Execution "Ignoring error $($_)"
          }
          else 
          {
              throw $_.Exception
          }
      }
  
      Trace-Execution "[END][$operation]"
      return $output
  }
 
  function Test-ArtifactsOnDisk 
  {
      param 
      (
          [string] $Path
      )
  
      $expectedArtifacts = @('ArcA_EphemeralData_IRVM01_1.vhdx', 'ArcA_LocalData_IRVM01_1.vhdx', 'ArcA_SharedData_IRVM01.vhdx', 'Docker_IRVM01_1.vhdx', 'IRVM01.vhdx', '*.vmcx', '*.vmgs', '*.VMRS')
      foreach($expectedArtifact in $expectedArtifacts)
      {
          $artifactFile = Get-ChildItem -Path $Path -Recurse -File -Filter $expectedArtifact
  
          if($null -eq $artifactFile) 
          {
              return $false
          }
  
          if(-not (Test-Path $artifactFile.FullName))
          {
              return $false
          }
      }
      return $true
  }
  
function Get-ArcAFolder 
{
    $date = [datetime]::Now.ToString("MM-dd-yyyy") 
    $arcaFolder = "$arcaBaseFolder\$date"
    return $arcaFolder
}
  
 function DownloadLKG
 {
     param
     (
         $arcaBaseFolder = "F:\arca"
     )
 
     Trace-Execution "[START] Download LKG artifacts from AzureStackUniversalBuddy feed"
     $driveLetter = $arcaBaseFolder.Split(':')[0]
     $remainingSize = (Get-Volume -DriveLetter $driveLetter).SizeRemaining
     Trace-Execution "Remaining disk space in folder $arcaBaseFolder $($remainingSize/1Gb) Gb."
     if($remainingSize -lt 120Gb)
     {
         throw "expect atleast 120Gb to download artifacts"
     }
     
     $arcaFolder = Get-ArcAFolder
     mkdir $arcaFolder -Force | Out-Null
 
     if(Test-ArtifactsOnDisk -Path $arcaFolder)
     {
         Trace-Execution "Artifacts already exist in $arcaFolder skip download."
     }
     else
     {
         Trace-Execution "[START] download artifacts to $arcaFolder"
         # assumes PAT already set. See https://learn.microsoft.com/en-us/azure/devops/cli/log-in-via-pat?view=azure-devops&tabs=windows for details.
         $downloadOutput = az artifacts universal download --organization "https://msazure.visualstudio.com/" --feed "AzureStackUniversalBuddy" --name "arca.onenode.complete" --version "*" --path $arcaFolder
         $downloadedFiles = (Get-ChildItem -Path $arcaFolder -File -Recurse).FullName
         Trace-Execution "az artifacts universal download output: $downloadOutput"
         Trace-Execution "Following files were downloaded:`r`n$downloadedFiles`r`n"
     }

     Save-ArtifactMd5Hash -Path $arcaFolder
     Trace-Execution "[END] Download LKG artifacts"
     return $arcaFolder
 }
 
 # reads $blobUrl/control/control.json
 <#
 Sample control.json file:
 {
     "PreviousLKGContainer": "lkg",
     "CopyInProgress": false,
     "LKGContainer": "04-25-2023"
 }
 #>

 function Read-ControlData
 {
     $localDataFolder = "$env:APPDATA\Winfield"
     mkdir $localDataFolder -Force
     $downloadedControlDataFile = "$localDataFolder\downloadedControlDataFile.json"
     $azCopyParameters = @("cp", "$blobUrl/control/control.json$code", $downloadedControlDataFile)
     Invoke-AzCopy -operation "Read control data" -azCopyParameters $azCopyParameters
     $controlData = Get-Content $downloadedControlDataFile | ConvertFrom-Json
     Trace-Execution "LKG control data:`r`n $(Get-Content $downloadedControlDataFile)`r`n"
     return $controlData
 }
 
 # writes control.json file to blob. checks etag for concurrency control
 function Write-ControlData
 {
     param 
     (
         [string] $containerName,
         [string] $previousContainerName,
         [bool]   $copyInProgress,
         [string] $etag
     )
     
     $controlData = @{LKGContainer = $containerName; CopyInProgress=$copyInProgress; PreviousLKGContainer = $previousContainerName}
     $localDataFolder = "$env:APPDATA\Winfield"
     mkdir $localDataFolder -Force
     $controlDataFile = "$localDataFolder\control.json"
     $controlData | ConvertTo-Json | Out-File $controlDataFile -Encoding ascii
     
     $currEtag = Get-ControlDataEtag
     if($currEtag -eq $etag)
     {
         Trace-Execution "writing control data:`r`n $($controlData | Out-String) to $blobUrl/control/control.json"
         $azCopyParameters = @("cp", $controlDataFile, "$blobUrl/control$code")
         Invoke-AzCopy -operation "Write control data" -azCopyParameters $azCopyParameters
         Trace-Execution "control data file written to $blobUrl/control/control.json"
     }
     else 
     {
         Trace-Execution "Concurrency check failed. Given etag: $etag current etag: $currEtag"
         throw "Write-ControlData precondition failed. Given etag: $etag current etag: $currEtag"
     }
     
     return $true
 }
 
 # Get blob header via HEAD request, adds SAS token to the Uri
 function Get-BlobHeader
 {
     param
     (
         [string] $url,
         [string] $header
     )
 
     Trace-Execution "wget uri: $url"
     $urlWithCode = "$url$code"
     $urlWithCode = $urlWithCode.Replace('"','')
     $headerValue = $null
     try 
     {
        $response = Invoke-WebRequest -Uri $urlWithCode -Method Head -TimeoutSec 30
        $responseStatusCode = [int] $_.Exception.Response.StatusCode
        $headerValue = $response.Headers[$header]
     }
     catch 
     {
        if($null -ne $_.Exception.Response)
        {
            $responseStatusCode = [int] $_.Exception.Response.StatusCode
        }

        if($responseStatusCode -ne 404)
        {
            throw "$($_)"
        }
     }

     Trace-Execution "HEAD $url = http/$responseStatusCode"
     Trace-Execution "HEADER $header = $headerValue"
     return $headerValue
 }
 
 function Get-BlobEtag
 {
     param
     (
         [string] $url
     )
 
     $etag = Get-BlobHeader -url $url -header "ETag"
     Trace-Execution "ETAG $url = $etag"
     return $etag
 }

 function Get-BlobMD5Hash
 {
     param
     (
         [string] $url
     )
 
     $md5 = Get-BlobHeader -url $url -header "Content-MD5"
     $md5hash = $null
     if($null -ne $md5)
     {
        Trace-Execution "Content-MD5 $url = $md5"
        $md5Sum = [System.Convert]::FromBase64String($md5)
        $md5hash = [System.BitConverter]::ToString($md5Sum).Replace('-','')
        Trace-Execution "Blob MD5 $url = $md5hash"
     }
     return $md5hash
 }
 
 function Get-ControlDataEtag
 {
     $controlDataFile = "$blobUrl/control/control.json"
     return (Get-BlobEtag -url $controlDataFile)
 }
 


 # saves MD5 hash of downloaded artifacts in a json file
 function Save-ArtifactMd5Hash 
 {
    param 
    (
        [string] $Path
    )

    Trace-Execution "[START] compute md5 file hash for downloaded artifacts"
    $artifactFilePath = Join-Path $Path -ChildPath "artifacthash.json"
    
    if(-not (Test-Path $artifactFilePath))
    {
        Trace-Execution "$artifactFilePath doeesn't exist, computing MD5 file hashes."
        $fileHashes = @()
        $artifacts = Get-ChildItem -Path $Path -Recurse -File
        foreach($artifact in $artifacts)
        {
            $md5Hash = (Get-FileHash -Path $artifact.FullName -Algorithm MD5).Hash
            $fileHashes += @{FileName = $artifact.Name; FileFullName = $artifact.FullName; MD5 = $md5Hash}
        }

        $artifactHash = @{ArtifactHashInfo = $fileHashes}

        $artifactHash | ConvertTo-Json | Out-File $artifactFilePath -Force -Verbose
        
    }
    
    Trace-Execution "Downloaded Artifact file hashes stored at $artifactFilePath, contents:`r`n$(Get-Content $artifactFilePath)`r`n"
    Trace-Execution "[END] compute md5 file hashes"
 }

# compare MD5 file hash on file system file and blob file
# todo: cache MD5 hash results
 function Compare-Md5Hash 
{
    param 
    (
        $fileSource,
        $blobDestination
    )

    Trace-Execution "Comparing MD5 hash fileSource: $fileSource and blobDestination: $blobDestination"
    if(Test-Path $fileSource)
    {
        #$fileMd5 = (Get-FileHash -Algorithm MD5 -Path $fileSource).Hash
        $artifactHashFile = Join-Path -path (Get-ArcAFolder) -ChildPath "artifacthash.json"
        $fileHashes = (Get-Content -Path $artifactHashFile | ConvertFrom-Json).ArtifactHashInfo
        $fileMd5 = ($fileHashes | Where-Object{$_.FileFullName -eq $fileSource}).MD5
        $blobMd5 = Get-BlobMD5Hash -url "$blobDestination"
        Trace-Execution "File MD5 = $fileMd5 Blob MD5 = $blobMd5"
        return ($fileMd5 -eq $blobMd5)
    }
    return $false
}

 function UploadLKGtoBlob
 {
     param
     (
         [string] $arcaFolder = "F:\arca"
     )
 
     Trace-Execution "[START] Upload files from $arcaFolder to Blob storage"
 
     if(Test-Path -Path $arcaFolder)
     {
         $date = [datetime]::Now.ToString("MM-dd-yyyy")
         $blobLKG = "$blobUrl/lkg-$date"
 
         # indicate copy is in progress
         $controlData = Read-ControlData
         $currentEtag = Get-ControlDataEtag
         Write-ControlData -blobUrl $blobUri -containerName $date -previousContainerName $controlData.LKGContainer -copyInProgress $true -etag $currentEtag
 
         # azcopy cp "/path/to/dir" "https://[account].blob.core.windows.net/[container]/[path/to/directory]?[SAS]" --recursive=true --put-md5
         $artifactsToUpload = Get-ChildItem -Path $arcaFolder -Recurse -File

         try
         {
             foreach($artifact in $artifactsToUpload)
             {
                $blobLocation = "$blobLKG/$($artifact.Name)"
                Trace-Execution "Checking to see if $blobLocation exists."
                $blobExists = Test-BlobPath -Path $blobLocation
                $md5AreEqual = Compare-Md5Hash -fileSource $artifact.FullName -blobDestination $blobLocation
                if($blobExists -and $md5AreEqual)
                {
                    Trace-Execution "Blob $blobLocation exists with same MD5 hash, skipping upload."
                }
                else 
                {
                    $artifact = '"' + $artifact.FullName + '"'        
                    Trace-Execution "[START] Copy $artifact to $blobLKG"
                    Invoke-AzCopy -operation "copy" -azCopyParameters @("cp", $artifact, "$blobLKG$code", "--recursive=true", "--put-md5")
                }
             }

            Trace-Execution "Winfield artifacts from folder $arcaFolder uploaded to $blobUrl/lkg-$date"
            $currentEtag = Get-ControlDataEtag
            Write-ControlData -blobUrl $blobUri -containerName $date -previousContainerName $controlData.LKGContainer -copyInProgress $false -etag $currentEtag
            Trace-Execution "control data updated."
         }
         catch 
         {
             Trace-Execution "error uploading artifacts:`r`n $($_). `r`nUpdating control data to use previous LKG."
             $currentEtag = Get-ControlDataEtag
             Write-ControlData -blobUrl $blobUri -containerName $date -previousContainerName $controlData.PreviousLKGContainer -copyInProgress $false -etag $currentEtag
         }
     }
     else 
     {
         Trace-Execution "ArcA artifact folder $arcaFolder does not exist."
     }
     Trace-Execution "[END] Upload to Blob storage"
 }
 
 function Verify
 {
     param
     (
         [string] $containerName,
         [string] $arcaFolder
     )
 
     $uploadVerified = $false
     
     Trace-Execution "[START] Verify upload"
     $blobArtifacts = Get-BlobChildItem -containerName $containerName -pattern "."
     Trace-Execution "Artifacts in $containerName`: $blobArtifacts"
     Trace-Execution "Get artifacts on local filesystem path: $arcaFolder"
     $fileArtifacts = Get-ChildItem -Path $arcaFolder -File -Recurse -Filter "*.*"
     if($fileArtifacts.count -ne $blobArtifacts.count)
     {
         Trace-Execution "Expected files: $($fileArtifacts.FullName) found files in blob: $vhdBlobFiles"
         throw "did not find expected vhdx files in $containerName"
     }
     else 
     {
        foreach($fileArtifact in $fileArtifacts)
        {
            $date = [datetime]::Now.ToString("MM-dd-yyyy")
            $blobLKG = "$blobUrl/lkg-$date"
            $blobLocation = "$blobLKG/$($fileArtifact.Name)"
            Trace-Execution "Checking to see if $blobLocation exists."
            $blobExists = Test-BlobPath -Path $blobLocation
            $md5AreEqual = Compare-Md5Hash -fileSource $fileArtifact.FullName -blobDestination $blobLocation
            if($blobExists -and $md5AreEqual)
            {
                Trace-Execution "[VERIFY] $($fileArtifact.FullName) and blob $blobLocation MD5 match."
            }
            else 
            {
                Trace-Execution "[VERIFY] $($fileArtifact.FullName) and blob $blobLocation do not match."
                throw "Verify failed"
            }
        }
        $uploadVerified = $true
     }
 
     Trace-Execution "[END] Verify upload"
     return $uploadVerified
 }
 
 #
 # main
 #

  # START Transcript
 $timestamp = [DateTime]::Now.ToString("yyyyMMdd-HHmmss")
 $logPath = (New-Item -Path "$env:ProgramData\Microsoft\Winfield\Logs" -ItemType Directory -Force).FullName
 $logFile = Join-Path -Path $logPath -ChildPath "ImportWinField_${timestamp}.txt"
 try { Start-Transcript -Path $logFile -Force | Out-String | Write-Verbose -Verbose } catch { Write-Warning -Message $_.Exception.Message }
 
 $Global:blobUrl = 'https://winfieldartifacts.blob.core.windows.net'
 $sw = [System.Diagnostics.Stopwatch]::StartNew()
 try 
 {
    Test-Prereqs
    Init

    # Download
    Trace-Execution "downloading to $arcaBaseFolder"
    $downloadedArtifactsFolder = DownloadLKG -arcaBaseFolder $arcaBaseFolder
    $downloadedArtifactsFolder += "\IRVM01"
    
    # Upload
    $containerName = "lkg-$([datetime]::Now.ToString("MM-dd-yyyy"))"
    Trace-Execution "Creating blob container $containerName"
    New-BlobContainer -containerName $containerName
    Trace-Execution "Uploading $downloadedArtifactsFolder folder contents recursively to blob container $containerName"
    UploadLKGtoBlob -arcaFolder "$downloadedArtifactsFolder"

    # Verify upload
    Trace-Execution "Verify $downloadedArtifactsFolder contents were uploaded to blob container $containerName"
    Verify -containerName $containerName -arcaFolder $downloadedArtifactsFolder
 }
 catch 
 {
    Trace-Execution "Error uploading artifacts:`r`n$($_)`r`n"
 }
 finally
 {
    $sw.Stop()
    Trace-Execution "Script completed execution in $($sw.Elapsed.TotalSeconds) sec."
    try { Stop-Transcript | Out-String | Write-Verbose -Verbose } catch { Write-Warning -Message $_.Exception.Message }
 }