Public/Send-JuribaAppRSetupFile.ps1
|
function Send-JuribaAppRSetupFile { <# .SYNOPSIS Uploads a setup file to Juriba App Readiness using chunked upload. .DESCRIPTION Uploads a local setup file (MSI, EXE, ZIP, etc.) to App Readiness for automated processing. The file is split into chunks, uploaded individually, and then combined server-side. Returns an upload identifier (UUID) that can be passed to New-JuribaAppRApplication to create the application. The server combines the chunks ASYNCHRONOUSLY, so after requesting the combine this cmdlet polls GET api/v2/uploadChunk/{uuid}/state until the server confirms the file is fully assembled (or the combine fails). Without this wait, creating an application immediately after upload can start packaging before the source file exists in the store - the job then sits at "Downloading" with no logs and no VM allocated. On servers older than 5.1 (no state endpoint) or multi-node instances where the poll lands on a different node, the cmdlet warns and falls back to the previous fire-and-forget behaviour rather than failing the upload. Large files are handled automatically by splitting into configurable chunk sizes (default 2MB). Progress is reported via Write-Progress. .PARAMETER Instance The URL of the App Readiness instance. Not required if connected via Connect-JuribaAppR. .PARAMETER APIKey The API key for authentication. Not required if connected via Connect-JuribaAppR. .PARAMETER FilePath The full path to the setup file to upload. .PARAMETER ChunkSizeMB The size of each upload chunk in megabytes. Default is 2MB. Increase for faster uploads on high-bandwidth connections. .PARAMETER Protected When specified, uploads the file to the protected upload endpoint. Use this for files that require additional security handling. .PARAMETER CombineTimeoutSec Maximum seconds to wait for the server-side chunk combine to complete. Default is 300. Raise this for very large files or slow storage. .PARAMETER SkipCombineWait Skip waiting for the server-side combine and return as soon as the combine has been requested (the pre-0.3.8 behaviour). Only use this when the caller performs its own readiness check before creating the application. .EXAMPLE $upload = Send-JuribaAppRSetupFile -FilePath "C:\Installers\Firefox-Setup-115.0.exe" $upload.Uuid Uploads a setup file and returns the upload identifier. .EXAMPLE $upload = Send-JuribaAppRSetupFile -FilePath "C:\Installers\BigApp.msi" -ChunkSizeMB 5 Uploads a large file using 5MB chunks. .EXAMPLE $upload = Send-JuribaAppRSetupFile -FilePath "C:\Installers\App.exe" New-JuribaAppRApplication -Uuid $upload.Uuid -FileName $upload.FileName Uploads a file and immediately creates an application from it. #> [CmdletBinding()] param ( [Parameter(Mandatory = $false)] [string]$Instance, [Parameter(Mandatory = $false)] [string]$APIKey, [Parameter(Mandatory = $true)] [ValidateScript({ Test-Path $_ -PathType Leaf })] [string]$FilePath, [Parameter(Mandatory = $false)] [ValidateRange(1, 100)] [int]$ChunkSizeMB = 2, [Parameter(Mandatory = $false)] [switch]$Protected, [Parameter(Mandatory = $false)] [ValidateRange(10, 3600)] [int]$CombineTimeoutSec = 300, [Parameter(Mandatory = $false)] [switch]$SkipCombineWait ) $conn = Get-JuribaAppRConnection -Instance $Instance -APIKey $APIKey # Resolve the full path and get file info $fileInfo = Get-Item $FilePath $fileName = $fileInfo.Name $fileSize = $fileInfo.Length $chunkSize = $ChunkSizeMB * 1024 * 1024 $totalChunks = [int][Math]::Ceiling($fileSize / $chunkSize) $uuid = [Guid]::NewGuid().ToString() Write-Verbose "Uploading '$fileName' ($([Math]::Round($fileSize / 1MB, 2)) MB) in $totalChunks chunk(s)" Write-Verbose "Upload UUID: $uuid" # Determine the upload endpoint $chunkEndpoint = if ($Protected) { "api/uploadChunk/protected" } else { "api/uploadChunk" } $combineEndpoint = if ($Protected) { "api/v2/uploadChunk/protected/async" } else { "api/v2/uploadChunk/async" } $headers = @{ "x-api-key" = $conn.APIKey "Accept" = "application/json" } # Resolve the current user ID — upload endpoint requires userId in the form data $userId = "0" try { $whoAmI = Invoke-RestMethod -Uri "$($conn.Instance)/api/apm/user/whoAmI" ` -Headers $headers -Method GET if ($whoAmI) { $userId = $whoAmI.ToString().Trim() } Write-Verbose "Resolved userId: $userId" } catch { Write-Verbose "Could not resolve userId: $($_.Exception.Message)" } # Upload each chunk $fileStream = [System.IO.File]::OpenRead($fileInfo.FullName) try { $buffer = New-Object byte[] $chunkSize $chunkIndex = 0 while ($chunkIndex -lt $totalChunks) { $bytesRead = $fileStream.Read($buffer, 0, $chunkSize) # Create a temp file for the chunk, using the original filename # so the multipart Content-Disposition has the correct extension. # The server validates the filename and rejects .tmp files. $chunkTempDir = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), $uuid) if (-not (Test-Path $chunkTempDir)) { $null = New-Item -Path $chunkTempDir -ItemType Directory } $chunkTempPath = [System.IO.Path]::Combine($chunkTempDir, $fileName) try { [System.IO.File]::WriteAllBytes($chunkTempPath, $buffer[0..($bytesRead - 1)]) # Build multipart form data using HttpClient for full control over # headers. PowerShell's Invoke-WebRequest -Form may not reliably # pass custom headers (x-api-key) with multipart uploads. $chunkUri = "{0}/{1}" -f $conn.Instance, $chunkEndpoint $chunkByteOffset = $chunkIndex * $chunkSize $percentComplete = [Math]::Round(($chunkIndex + 1) / $totalChunks * 100) Write-Progress -Activity "Uploading $fileName" ` -Status "Chunk $($chunkIndex + 1) of $totalChunks" ` -PercentComplete $percentComplete Write-Verbose "Uploading chunk $($chunkIndex + 1)/$totalChunks ($bytesRead bytes)" try { $httpClient = [System.Net.Http.HttpClient]::new() $httpClient.DefaultRequestHeaders.Add("x-api-key", $conn.APIKey) $httpClient.DefaultRequestHeaders.Add("Authorization", "Bearer $($conn.APIKey)") $httpClient.DefaultRequestHeaders.Add("Accept", "application/json") $multipartContent = [System.Net.Http.MultipartFormDataContent]::new() # Add Dropzone.js 5.9.3 chunked upload fields $multipartContent.Add([System.Net.Http.StringContent]::new($uuid), "dzUuid") $multipartContent.Add([System.Net.Http.StringContent]::new($chunkIndex.ToString()), "dzChunkIndex") $multipartContent.Add([System.Net.Http.StringContent]::new($fileSize.ToString()), "dzTotalFileSize") $multipartContent.Add([System.Net.Http.StringContent]::new($bytesRead.ToString()), "dzCurrentChunkSize") $multipartContent.Add([System.Net.Http.StringContent]::new($totalChunks.ToString()), "dzTotalChunkCount") $multipartContent.Add([System.Net.Http.StringContent]::new($chunkByteOffset.ToString()), "dzChunkByteOffset") $multipartContent.Add([System.Net.Http.StringContent]::new($chunkSize.ToString()), "dzChunkSize") $multipartContent.Add([System.Net.Http.StringContent]::new($fileName), "dzFilename") $multipartContent.Add([System.Net.Http.StringContent]::new($userId), "userId") # Add the file content $chunkBytes = [System.IO.File]::ReadAllBytes($chunkTempPath) $fileContent = [System.Net.Http.ByteArrayContent]::new($chunkBytes) $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::new("application/octet-stream") $multipartContent.Add($fileContent, "file", $fileName) $uploadResponse = $httpClient.PostAsync($chunkUri, $multipartContent).GetAwaiter().GetResult() if (-not $uploadResponse.IsSuccessStatusCode) { $respBody = $uploadResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult() throw "HTTP $([int]$uploadResponse.StatusCode) $($uploadResponse.ReasonPhrase): $respBody" } } catch { $fileStream.Close() $fileStream.Dispose() Write-Progress -Activity "Uploading $fileName" -Completed throw "Chunk $($chunkIndex + 1)/$totalChunks upload failed: $($_.Exception.Message)" } finally { if ($multipartContent) { $multipartContent.Dispose() } if ($httpClient) { $httpClient.Dispose() } } } finally { if (Test-Path $chunkTempPath) { Remove-Item $chunkTempPath -Force -ErrorAction SilentlyContinue } } $chunkIndex++ } } finally { $fileStream.Close() $fileStream.Dispose() # Clean up the temp chunk directory if ($chunkTempDir -and (Test-Path $chunkTempDir)) { Remove-Item $chunkTempDir -Recurse -Force -ErrorAction SilentlyContinue } } Write-Progress -Activity "Uploading $fileName" -Completed # Combine the chunks on the server # Field names must match the CombineFilesModel expected by the API Write-Verbose "Combining chunks on server..." $combineBody = @{ dzIdentifier = $uuid fileName = $fileName totalChunks = $totalChunks expectedBytes = $fileSize uploadType = 0 } $combineUri = "{0}/{1}" -f $conn.Instance, $combineEndpoint $jsonBody = $combineBody | ConvertTo-Json -Compress Write-Verbose "Combine URI: $combineUri" Write-Verbose "Combine body: $jsonBody" try { $null = Invoke-RestMethod -Uri $combineUri -Method PUT ` -Headers $headers -ContentType 'application/json' -Body $jsonBody } catch { $errDetail = $_.Exception.Message if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $errDetail = $_.ErrorDetails.Message } throw "Combine failed: $errDetail" } # The combine endpoint is ASYNC: the PUT above only queues a background # job, and the server returns 200 before the file exists in the store. # Creating an application (with run-immediately) at that point races the # combine - if packaging wins, the job hangs at "Downloading" with no # logs and no VM, because the source it wants to fetch isn't there yet. # So poll the combine state until the server confirms completion. # # 404 from the state endpoint means no state entry is visible HERE: # the server predates the endpoint (< 5.1), the poll landed on a # different node behind a load balancer (state is per-node), or the # entry expired (~10 min lifetime). After several consecutive 404s we # warn and fall back to the previous fire-and-forget behaviour rather # than fail an upload that may be completing fine. if (-not $SkipCombineWait) { Write-Verbose "Waiting for server-side chunk combine (timeout: ${CombineTimeoutSec}s)..." $stateUri = "{0}/api/v2/uploadChunk/{1}/state" -f $conn.Instance, $uuid $deadline = (Get-Date).AddSeconds($CombineTimeoutSec) $confirmed = $false $unverifiable = $false $notFoundStreak = 0 while (-not $confirmed -and -not $unverifiable) { if ((Get-Date) -ge $deadline) { throw "Timed out after $CombineTimeoutSec seconds waiting for the server to combine '$fileName' (upload id $uuid). The combine may still finish in the background - check the instance before re-uploading, or raise -CombineTimeoutSec." } Start-Sleep -Seconds 2 $state = $null try { $state = Invoke-RestMethod -Uri $stateUri -Method GET -Headers $headers $notFoundStreak = 0 } catch { $statusCode = 0 if ($_.Exception.Response) { try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { $statusCode = 0 } } if ($statusCode -eq 404) { $notFoundStreak++ if ($notFoundStreak -ge 5) { Write-Warning ("Could not verify the server-side chunk combine for '{0}' - the state endpoint returned 404 repeatedly. This is expected on servers older than 5.1 or multi-node instances without session affinity. Proceeding without confirmation; consider waiting before creating the application." -f $fileName) $unverifiable = $true } } elseif ($statusCode -eq 401 -or $statusCode -eq 403) { # The key could upload but can't read combine state # (the state endpoint needs Administrators/Packager/ # SiteAdmin/AppOwner). Retrying won't change that, so # degrade immediately rather than failing a good # upload at the timeout. Write-Warning ("Could not verify the server-side chunk combine for '{0}' - this API key isn't authorised to read combine state (HTTP {1}). Proceeding without confirmation; consider waiting before creating the application." -f $fileName, $statusCode) $unverifiable = $true } else { Write-Verbose "Combine state poll failed (will retry): $($_.Exception.Message)" } } if ($state -and $state.isCompleted) { if (-not $state.status) { $combineError = if ($state.error) { $state.error } else { "error code $($state.errorCode)" } throw "Server-side chunk combine failed for '$fileName': $combineError" } $confirmed = $true Write-Verbose "Chunk combine completed on server." } } } Write-Verbose "Upload complete. UUID: $uuid" # Extract FileVersionInfo metadata (ProductName, CompanyName, etc.) # This is the same data the Angular UI reads client-side before posting. $productName = $null $companyName = $null $productVersion = $null try { $versionInfo = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($fileInfo.FullName) if ($versionInfo.ProductName) { $productName = $versionInfo.ProductName.Trim() } if ($versionInfo.CompanyName) { $companyName = $versionInfo.CompanyName.Trim() } if ($versionInfo.ProductVersion) { $productVersion = $versionInfo.ProductVersion.Trim() } elseif ($versionInfo.FileVersion) { $productVersion = $versionInfo.FileVersion.Trim() } Write-Verbose "File metadata: Name='$productName', Manufacturer='$companyName', Version='$productVersion'" } catch { Write-Verbose "Could not read FileVersionInfo: $($_.Exception.Message)" } # Return an object with the upload details needed for New-JuribaAppRApplication [PSCustomObject]@{ Uuid = $uuid FileName = $fileName FileSize = $fileSize TotalChunks = $totalChunks Name = $productName Manufacturer = $companyName Version = $productVersion } } |