Public/Invoke-WingetFleet.ps1
|
function Invoke-WingetFleet { <# .SYNOPSIS Execute winget operations across multiple remote machines simultaneously. .DESCRIPTION SCCM-lite for people who hate SCCM. Push package installs, updates, uninstalls, and state queries to N machines over WinRM or SSH with parallel execution, aggregated reporting, and failure resilience. Supports machine lists from files, Active Directory OUs, or inline arrays. Results are collected into a structured report with per-machine status. .PARAMETER Computers Array of computer names/IPs to target. .PARAMETER ComputerFile Path to a text file with one computer name per line. .PARAMETER Action Operation to perform: Install, Uninstall, Update, UpdateAll, List, State, Query. .PARAMETER PackageId Package ID(s) for Install/Uninstall/Query actions. .PARAMETER Credential PSCredential for remote authentication. Defaults to current user. .PARAMETER UseSSH Use SSH instead of WinRM for remote connectivity. .PARAMETER ThrottleLimit Maximum concurrent remote sessions. Default: 10. .PARAMETER TimeoutSeconds Per-machine operation timeout. Default: 300. .PARAMETER ExportReport Save aggregated results to a JSON report file. .PARAMETER RetryFailed Automatically retry failed machines once. .EXAMPLE Invoke-WingetFleet -Computers "PC01","PC02","PC03" -Action UpdateAll Updates all packages on 3 machines in parallel. .EXAMPLE Invoke-WingetFleet -ComputerFile ".\lab-machines.txt" -Action Install -PackageId "Git.Git","Python.Python.3.12" Installs Git and Python on all machines in the file. .EXAMPLE Invoke-WingetFleet -Computers "Server01" -Action State -Credential (Get-Credential) Queries full package state on Server01 with explicit credentials. .EXAMPLE Invoke-WingetFleet -ComputerFile ".\fleet.txt" -Action List -ExportReport ".\fleet-report.json" Lists all packages on every machine and exports a JSON report. .NOTES Author: Matthew Bubb Requires: WinRM (Enable-PSRemoting) or SSH configured on target machines. WingetBatch module must be available on remote machines for full functionality. #> [CmdletBinding(DefaultParameterSetName = 'Inline')] param( [Parameter(ParameterSetName = 'Inline', Mandatory, Position = 0)] [Alias('Machines', 'Hosts')] [string[]]$Computers, [Parameter(ParameterSetName = 'File', Mandatory)] [ValidateScript({ Test-Path $_ })] [string]$ComputerFile, [Parameter(Mandatory)] [ValidateSet('Install', 'Uninstall', 'Update', 'UpdateAll', 'List', 'State', 'Query')] [string]$Action, [Parameter()] [string[]]$PackageId, [Parameter()] [PSCredential]$Credential, [switch]$UseSSH, [ValidateRange(1, 50)] [int]$ThrottleLimit = 10, [ValidateRange(30, 3600)] [int]$TimeoutSeconds = 300, [string]$ExportReport, [switch]$RetryFailed ) # --- Resolve computer list --- if ($ComputerFile) { $Computers = Get-Content -Path $ComputerFile | Where-Object { $_.Trim() -and -not $_.StartsWith('#') } | ForEach-Object { $_.Trim() } } if (-not $Computers -or $Computers.Count -eq 0) { Write-Error "No target computers specified." return } # Validate package IDs for actions that need them if ($Action -in @('Install', 'Uninstall', 'Update', 'Query') -and -not $PackageId) { Write-Error "Action '$Action' requires -PackageId parameter." return } # --- Banner --- Write-Host "" Write-Host " ╔══════════════════════════════════════════════════════╗" -ForegroundColor Cyan Write-Host " ║ WingetBatch Fleet Operations ║" -ForegroundColor Cyan Write-Host " ╚══════════════════════════════════════════════════════╝" -ForegroundColor Cyan Write-Host "" Write-Host " Targets: " -NoNewline -ForegroundColor DarkGray; Write-Host "$($Computers.Count) machines" -ForegroundColor White Write-Host " Action: " -NoNewline -ForegroundColor DarkGray; Write-Host $Action -ForegroundColor Yellow if ($PackageId) { Write-Host " Packages: " -NoNewline -ForegroundColor DarkGray; Write-Host ($PackageId -join ', ') -ForegroundColor White } Write-Host " Transport: " -NoNewline -ForegroundColor DarkGray; Write-Host $(if ($UseSSH) { "SSH" } else { "WinRM" }) -ForegroundColor White Write-Host " Parallel: " -NoNewline -ForegroundColor DarkGray; Write-Host "$ThrottleLimit concurrent" -ForegroundColor White Write-Host "" # --- Build remote script block --- $remoteScript = { param($ActionParam, $PackageIds, $TimeoutSec) $result = @{ Computer = $env:COMPUTERNAME Action = $ActionParam Timestamp = (Get-Date -ToString 'o') Status = 'Unknown' Packages = @() Errors = @() DurationMs = 0 } $sw = [System.Diagnostics.Stopwatch]::StartNew() try { # Ensure winget COM is available $comAvailable = $null -ne (Get-Module -ListAvailable Microsoft.WinGet.Client) if (-not $comAvailable) { $result.Status = 'Error' $result.Errors += "Microsoft.WinGet.Client not available on this machine" return ($result | ConvertTo-Json -Depth 5 -Compress) } Import-Module Microsoft.WinGet.Client -ErrorAction Stop switch ($ActionParam) { 'List' { $pkgs = Get-WinGetPackage $result.Packages = @($pkgs | ForEach-Object { @{ Id = $_.Id; Name = $_.Name; Version = $_.InstalledVersion; Source = $_.Source } }) $result.Status = 'Success' } 'State' { $pkgs = Get-WinGetPackage $result.Packages = @($pkgs | ForEach-Object { @{ Id = $_.Id; Name = $_.Name; Version = $_.InstalledVersion; Source = $_.Source; Available = $_.AvailableVersions } }) $result.TotalCount = $pkgs.Count $result.UpdatesAvailable = @($pkgs | Where-Object { $_.AvailableVersions -and $_.AvailableVersions[0] -ne $_.InstalledVersion }).Count $result.Status = 'Success' } 'Install' { foreach ($id in $PackageIds) { try { Install-WinGetPackage -Id $id -Mode Silent | Out-Null $result.Packages += @{ Id = $id; Status = 'Installed' } } catch { $result.Packages += @{ Id = $id; Status = 'Failed'; Error = $_.Exception.Message } } } $failedCount = @($result.Packages | Where-Object { $_.Status -eq 'Failed' }).Count $result.Status = if ($failedCount -eq 0) { 'Success' } else { 'PartialFailure' } } 'Uninstall' { foreach ($id in $PackageIds) { try { Uninstall-WinGetPackage -Id $id -Mode Silent | Out-Null $result.Packages += @{ Id = $id; Status = 'Uninstalled' } } catch { $result.Packages += @{ Id = $id; Status = 'Failed'; Error = $_.Exception.Message } } } $failedCount = @($result.Packages | Where-Object { $_.Status -eq 'Failed' }).Count $result.Status = if ($failedCount -eq 0) { 'Success' } else { 'PartialFailure' } } 'Update' { foreach ($id in $PackageIds) { try { Update-WinGetPackage -Id $id -Mode Silent | Out-Null $result.Packages += @{ Id = $id; Status = 'Updated' } } catch { $result.Packages += @{ Id = $id; Status = 'Failed'; Error = $_.Exception.Message } } } $failedCount = @($result.Packages | Where-Object { $_.Status -eq 'Failed' }).Count $result.Status = if ($failedCount -eq 0) { 'Success' } else { 'PartialFailure' } } 'UpdateAll' { $pkgs = Get-WinGetPackage $updatable = $pkgs | Where-Object { $_.AvailableVersions -and $_.AvailableVersions[0] -ne $_.InstalledVersion } foreach ($pkg in $updatable) { try { Update-WinGetPackage -Id $pkg.Id -Mode Silent | Out-Null $result.Packages += @{ Id = $pkg.Id; Status = 'Updated'; Version = $pkg.AvailableVersions[0] } } catch { $result.Packages += @{ Id = $pkg.Id; Status = 'Failed'; Error = $_.Exception.Message } } } $result.UpdatesAvailable = $updatable.Count $failedCount = @($result.Packages | Where-Object { $_.Status -eq 'Failed' }).Count $result.Status = if ($failedCount -eq 0) { 'Success' } else { 'PartialFailure' } } 'Query' { foreach ($id in $PackageIds) { $found = Get-WinGetPackage -Id $id -ErrorAction SilentlyContinue if ($found) { $result.Packages += @{ Id = $id; Installed = $true; Version = $found.InstalledVersion; Name = $found.Name } } else { $result.Packages += @{ Id = $id; Installed = $false } } } $result.Status = 'Success' } } } catch { $result.Status = 'Error' $result.Errors += $_.Exception.Message } $sw.Stop() $result.DurationMs = $sw.ElapsedMilliseconds return ($result | ConvertTo-Json -Depth 5 -Compress) } # --- Execute across fleet --- $results = [System.Collections.Generic.List[hashtable]]::new() $sessionParams = @{} if ($Credential) { $sessionParams['Credential'] = $Credential } $total = $Computers.Count $completed = 0 $batchSize = $ThrottleLimit for ($batchStart = 0; $batchStart -lt $total; $batchStart += $batchSize) { $batch = $Computers[$batchStart..([Math]::Min($batchStart + $batchSize - 1, $total - 1))] $jobs = @() foreach ($computer in $batch) { $completed++ Write-Progress -Activity "Fleet: $Action" -Status "[$completed/$total] $computer" -PercentComplete (($completed / $total) * 100) if ($UseSSH) { $jobs += Invoke-Command -HostName $computer -ScriptBlock $remoteScript -ArgumentList $Action, $PackageId, $TimeoutSeconds @sessionParams -AsJob -ErrorAction SilentlyContinue } else { $jobs += Invoke-Command -ComputerName $computer -ScriptBlock $remoteScript -ArgumentList $Action, $PackageId, $TimeoutSeconds @sessionParams -AsJob -ErrorAction SilentlyContinue } } # Wait for batch $jobs | Wait-Job -Timeout $TimeoutSeconds | Out-Null # Collect results foreach ($job in $jobs) { try { $output = Receive-Job $job -ErrorAction Stop if ($output) { $parsed = $output | ConvertFrom-Json -AsHashtable $results.Add($parsed) } else { $results.Add(@{ Computer = "Unknown"; Status = 'NoResponse'; Errors = @('Job returned no output') }) } } catch { $results.Add(@{ Computer = "Unknown"; Status = 'Error'; Errors = @($_.Exception.Message) }) } Remove-Job $job -Force -ErrorAction SilentlyContinue } } Write-Progress -Activity "Fleet: $Action" -Completed # --- Retry failed --- if ($RetryFailed) { $failedMachines = $results | Where-Object { $_.Status -in @('Error', 'NoResponse') } | ForEach-Object { $_.Computer } if ($failedMachines.Count -gt 0) { Write-Host " Retrying $($failedMachines.Count) failed machines..." -ForegroundColor Yellow foreach ($computer in $failedMachines) { try { $output = Invoke-Command -ComputerName $computer -ScriptBlock $remoteScript -ArgumentList $Action, $PackageId, $TimeoutSeconds @sessionParams -ErrorAction Stop $parsed = $output | ConvertFrom-Json -AsHashtable # Replace the failed result $idx = $results.FindIndex({ param($r) $r.Computer -eq $computer -and $r.Status -in @('Error', 'NoResponse') }) if ($idx -ge 0) { $results[$idx] = $parsed } } catch { } } } } # --- Aggregate Report --- $successCount = ($results | Where-Object { $_.Status -eq 'Success' }).Count $partialCount = ($results | Where-Object { $_.Status -eq 'PartialFailure' }).Count $failedCount = ($results | Where-Object { $_.Status -in @('Error', 'NoResponse') }).Count Write-Host "" Write-Host " ┌─────────────────────────────────────────────────────┐" -ForegroundColor White Write-Host " │ Fleet Operation Results │" -ForegroundColor White Write-Host " ├─────────────────────────────────────────────────────┤" -ForegroundColor White Write-Host " │ " -NoNewline -ForegroundColor White Write-Host "Success: $successCount" -NoNewline -ForegroundColor Green Write-Host " | " -NoNewline -ForegroundColor White Write-Host "Partial: $partialCount" -NoNewline -ForegroundColor Yellow Write-Host " | " -NoNewline -ForegroundColor White Write-Host "Failed: $failedCount" -NoNewline -ForegroundColor Red Write-Host " │" -ForegroundColor White Write-Host " └─────────────────────────────────────────────────────┘" -ForegroundColor White Write-Host "" # Per-machine summary foreach ($r in ($results | Sort-Object { $_.Computer })) { $icon = switch ($r.Status) { 'Success' { '✓' } 'PartialFailure' { '◐' } default { '✗' } } $color = switch ($r.Status) { 'Success' { 'Green' } 'PartialFailure' { 'Yellow' } default { 'Red' } } $duration = if ($r.DurationMs) { " ($([Math]::Round($r.DurationMs / 1000, 1))s)" } else { "" } Write-Host " $icon " -NoNewline -ForegroundColor $color Write-Host "$($r.Computer)" -NoNewline -ForegroundColor White Write-Host " — $($r.Status)$duration" -ForegroundColor $color if ($r.Errors -and $r.Errors.Count -gt 0) { foreach ($err in $r.Errors | Select-Object -First 2) { Write-Host " $err" -ForegroundColor DarkGray } } } Write-Host "" # --- Export --- if ($ExportReport) { $report = @{ Timestamp = (Get-Date -ToString 'o') Action = $Action PackageIds = $PackageId TotalMachines = $total Success = $successCount PartialFailure = $partialCount Failed = $failedCount Results = $results } $report | ConvertTo-Json -Depth 10 | Set-Content -Path $ExportReport -Encoding UTF8 Write-Host " Report saved: $ExportReport" -ForegroundColor Green Write-Host "" } # Return structured results for pipeline $results | ForEach-Object { [PSCustomObject]$_ } } |