src/_Invoke-CciBlobRequest.ps1
|
function _Invoke-CciBlobRequest { <# .SYNOPSIS Minimal Azure Blob REST client (list / download / upload) using an Entra bearer token. .DESCRIPTION Replaces Az.Storage for cciget's purposes - see _Get-CciEntraToken for why the Az modules are avoided on freshly built machines. .PARAMETER Context Store context from _Connect-CciBlobStore (Account + Token). .PARAMETER Operation List, Download, or Upload. #> [CmdletBinding()] param( [Parameter(Mandatory)]$Context, [Parameter(Mandatory)][ValidateSet('List','Download','Upload')][string]$Operation, [Parameter(Mandatory)][string]$Container, [string]$Prefix, [string]$Blob, [string]$Path ) $base = "https://$($Context.Account).blob.core.windows.net" $headers = @{ Authorization = "Bearer $($Context.Token)" 'x-ms-version' = '2021-08-06' 'x-ms-date' = [DateTime]::UtcNow.ToString('R') } # Every request goes through this so no failure can be swallowed. A bare # `-ErrorAction Stop` was not enough: during the first pilot, 403s from # partially propagated RBAC were written to the error stream while the # install loop carried on and reported success over a half-downloaded # module. Failures now throw, and only genuinely transient statuses retry. $transient = @(408, 429, 500, 502, 503, 504) $request = { param([hashtable]$Splat, [string]$What) for ($attempt = 1; ; $attempt++) { try { return Invoke-WebRequest @Splat -UseBasicParsing -ErrorAction Stop } catch { $status = 0 $r = $_.Exception.Response if ($r -and $r.StatusCode) { $status = [int]$r.StatusCode } if ($transient -contains $status -and $attempt -lt 4) { Start-Sleep -Seconds ([Math]::Pow(2, $attempt)) continue } $detail = if ($status) { "HTTP $status" } else { $_.Exception.Message } throw "cciget: blob request failed ($detail): $What" } } } switch ($Operation) { 'List' { $names = [System.Collections.Generic.List[string]]::new() $marker = $null do { $uri = "$base/$Container`?restype=container&comp=list&maxresults=5000" if ($Prefix) { $uri += "&prefix=$([uri]::EscapeDataString($Prefix))" } if ($marker) { $uri += "&marker=$([uri]::EscapeDataString($marker))" } $resp = & $request @{ Uri = $uri; Headers = $headers } "list $Container/$Prefix" # Strip the BOM the service prefixes to the XML payload. $text = $resp.Content -replace '^[\uFEFF\u200B]+', '' [xml]$xml = $text foreach ($b in $xml.EnumerationResults.Blobs.Blob) { $names.Add($b.Name) } $marker = $xml.EnumerationResults.NextMarker } while ($marker) return $names } 'Download' { $uri = "$base/$Container/$(($Blob -split '/' | ForEach-Object { [uri]::EscapeDataString($_) }) -join '/')" $dir = Split-Path $Path -Parent if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $null = & $request @{ Uri = $uri; Headers = $headers; OutFile = $Path } "download $Blob" if (-not (Test-Path $Path)) { throw "cciget: download reported success but produced no file: $Blob" } return $Path } 'Upload' { $uri = "$base/$Container/$(($Blob -split '/' | ForEach-Object { [uri]::EscapeDataString($_) }) -join '/')" $bytes = [System.IO.File]::ReadAllBytes($Path) $putHeaders = $headers.Clone() $putHeaders['x-ms-blob-type'] = 'BlockBlob' $null = & $request @{ Uri = $uri; Method = 'Put'; Headers = $putHeaders; Body = $bytes ContentType = 'application/octet-stream' } "upload $Blob" return $Blob } } } |