Invoke-Command one to Many without timeouts - powershell

I am trying to run series of commands on remote Windows Servers.
Some of these servers are up and some of them not. To some I have access and to some of them I do not have access.
Options to address this:
I can go with:
$Computername = PC1, PC2, PC3
Invoke-Command -ComputerName $computerName -FilePath .\script
Now what bugs me is that the invoke-command timeout takes too long and cannot made shorter.
So what I was thinking about is to use PowerShell 7 Parallel loops + Invoke-Command + Powershell Jobs:
$server_list = Import-Csv .\server-list.csv
$list = $server_list.Name
$my_array = $list | Foreach-Object -ThrottleLimit 32 -Parallel {
$x = start-job -name $_ -ScriptBlock { invoke-command -ComputerName $_ -FilePath .\script.ps1}
$x | Wait-Job -Timeout 5
$x | Where-Object {$_.State -ne "Completed"} | Stop-Job
$zz = Receive-Job -Name $_
$zz
}
$my_array
Error I am getting is:
Invoke-Command: Cannot validate argument on parameter 'ComputerName'. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
Invoke-Command: Cannot validate argument on parameter 'ComputerName'. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
Invoke-Command: Cannot validate argument on parameter 'ComputerName'. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
Any idea how to address this?

This is how you can apply the logic you're looking for without so much overhead, you only really need a loop to enumerate each computer and Start-ThreadJob which comes pre-installed in PowerShell Core.
As for the error, you can use the $using: scope modifier when referring to variable defined in the local scope. Example 9 from the docs has a good explanation on this.
$jobs = foreach($computer in (Import-Csv .\server-list.csv).Name) {
Start-ThreadJob {
Invoke-Command -ComputerName $using:computer -FilePath .\script.ps1
} -ThrottleLimit 32
}
# Wait 5 seconds for them
$jobs | Wait-Job -Timeout 5
# Split the array between Completed and the other states // Running, Failed, etc.
$completed, $failed = $jobs.Where({ $_.State -eq 'Completed' }, 'Split')
# get the output from the completed ones
$completed | Receive-Job -Wait -AutoRemoveJob
# These maybe you want to output to console before removing?
$failed | Stop-Job -PassThru | Remove-Job

Related

How do I tell what kind of object is being passed to a function in powershell other than Get-Member?

I have two examples, one works because it is literal, and the other is a function and doesn't work.
The goal is to return $true if the path exists, however in my function it always returns false.
$keyToDelete = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\S-1-5-21-1202660629-261903793-682003330-60470"
$SuppliedKeyExists = Invoke-Command -computername $UserComputerName {Test-Path $args[0]} -ArgumentList $keyToDelete
write-host $SuppliedKeyExists
This code works, where this code does not:
$UserComputerName = PC1
$UserProfileToRebuild = user1
function Get-KeyToDelete($UserComputerName, $UserProfileToRebuild){
$WmiPathAndSID1 = Invoke-Command -ComputerName $UserComputerName -scriptblock {Get-WmiObject win32_userprofile | Select-Object localpath, sid}
$WmiPathAndSID2 = $WmiPathAndSID1 | Select-Object localpath, sid | Where-Object {$_.localpath -contains "C:\Users\$UserProfileToRebuild"}
$profileSID = $WmiPathAndSID2.sid | Out-String
$keyToDelete = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$profileSID"
return "$keyToDelete"
}
function Check-KeyToDelete($UserComputerName, $keyToDelete){ ##function checks for .bak/corrupted user profiles first
Write-Host $UserComputerName
Write-Host $keyToDelete
$SuppliedKeyExists = Invoke-Command -computername $UserComputerName {Test-Path $args[0]} -ArgumentList $keyToDelete
Write-Host $SuppliedKeyExists
function validateKey($keyToDelete){
Write-Host "Validating key $keyToDelete"
if ($keyToDelete.length -ne 110)
{
return "Invalid registry key selected for deletion, catastrophic execution, stopping program"
exit
}
else{Write-Host "The length of the registry key is valid at 110 characters. Proceeding with program."}
}
$CorruptedKeyExists = Invoke-Command -computername $UserComputerName {Test-Path $args[0]} -ArgumentList "$keyToDelete.bak"
Write-Host $SuppliedKeyExists
if($SuppliedKeyExists){
validateKey
return $keyToDelete
}
else{return "This Process Failed"}
}
Get-KeyToDelete $UserComputerName $UserProfileToRebuild
Check-KeyToDelete $UserComputerName $keyToDelete
The Write-Hosts in the second block are to verify what is being sent. $keyToDelete | get-member returns a string object and is apparently no different than just typing the string itself. So what is the difference in object type when I get it using a function, versus manually entering it?
I was able to resolve this myself by doing this:
$SuppliedKeyExists = Invoke-Command -computername $UserComputerName {Test-Path $args[0]} -ArgumentList "$keyToDelete"
I also removed the line with Out-String, which #AdminOfThings pointed out was causing a new line to be added to the var $keyToDelete
Thanks!

Adobe flash player powershell remote install problem

I'm attempting to develop a script with PowerShell to remotely install/update flash player for multiple machines. No matter what I do, I can't get the install to work properly at all. I'm very limited with my tools so I have to use PowerShell, and the MSI install of Flashplayer. I'll post my script below, any help at all would be greatly appreciated.
$Computers = Get-Content C:\Users\name\Desktop\flash.txt
(tried these 3 methods to install none work)
$install = #("/a","/i", "\\$Computer\c$\temp\flash\install_flash_player_32_plugin.msi", "/qn","/norestart")
Invoke-Command -ComputerName $Computer -ScriptBlock {Start-Process "Msiexec" -arg "$using:install" -Wait -PassThru} -Filepath msiexec.exe
#This returns with "invoke-command: parameter set cannot be resolved using the specified named parameters"
Invoke-Command -ComputerName $computer -ScriptBlock {Start-Process -Filepath msiexec.exe "$using:install" -Wait -PassThru} -Filepath msiexec.exe
#this returns the same error.
Invoke-Command -ComputerName $Computer -ScriptBlock {start-process msiexec -argumentlist #('/a','/i','"\\$Computer\c$\temp\flash\install_flash_player_32_plugin.msi"','/qn')}
#this seemingly skips the install entirely.
I've used similar scripts for other programs and had no problems installing them, but none of the methods I use or have researched are working properly.
This should do the trick, I'll explain why it wasn't working bellow:
$Computers = Get-Content C:\Users\name\Desktop\flash.txt
$params = '/i <path to AcroPro.msi> LANG_LIST=en_US TRANSFORMS="1033.mst" /qb'
$Computers | % {
Invoke-Command -ScriptBlock {
Param(
[Parameter(Mandatory=$true,Position=0)]
[String]$arguments
)
return Start-Process msiexec.exe -ArgumentList $arguments -Wait -PassThru
} -ComputerName $_ -ArgumentList $params
}
So, it wasn't working because the ScriptBlock on Invoke-Command cant see variables that you've declared on your powershell session, think of it like you are walking to that remote computer and inputting that code by hand, you wont have the value (metaphor).
I did a few more changes:
I moved all params into 1 single string, no need to have them in array.
Added $Computers | to iterate through computer names.
Removed FilePath as this is meant to be used differently, documentation(Example #1).
Set $MinutesToWait to whatever amount of minutes you want.
No need to try to pass msiexec, as it comes with windows the default path is "C:\WINDOWS\system32\msiexec.exe"
Added a return even though its never necessary, but to make it more readable and to show you intent to return the output of the msiexec process.
Replaced \\$Computer\c$ with C:\ as there's no need to use a network connection if you are pointing to the host you are running the command in/
Hope it helps, good luck.
EDIT:
So, as you mentioned the pipeline execution gets stuck, I had this issue in the past when creating the computer preparation script for my department, what I did was use jobs to create parallel executions of the installation so if there's a computer that for some reason is slower or is just flat out stuck and never ends you can identify it, try the following as is to see how it works and then do the replaces:
#region ######## SetUp #######
$bannerInProgress = #"
#######################
#Jobs are still running
#######################
"#
$bannerDone = #"
##################################################
#DONE see results of finished installations bellow
##################################################
"#
#VARS TO SET
$MinutesToWait = 1
$computers = 1..10 | % {"qwerty"*$_} #REPLACE THIS WITH YOUR COMPUTER VALUES (Get-Content C:\Users\name\Desktop\flash.txt)
#endregion
#region ######## Main #######
#Start Jobs (REPLACE SCRIPTBLOCK OF JOB WITH YOUR INVOKE-COMMAND)
$jobs = [System.Collections.ArrayList]::new()
foreach($computer in $computers){
$jobs.Add(
(Start-Job -Name $computer -ScriptBlock {
Param(
[Parameter(Mandatory=$true, Position=0)]
[String]$computer
)
Sleep -s (Get-Random -Minimum 5 -Maximum 200)
$computer
} -ArgumentList $computer)
) | Out-Null
}
$timer = [System.Diagnostics.Stopwatch]::new()
$timer.Start()
$acceptedWait = $MinutesToWait * 60 * 1000 # mins -> sec -> millis
$running = $true
do {
cls
$jobsRunning = $jobs | Where-Object State -EQ 'Running'
if ($jobsRunning) {
Write-Host $bannerInProgress
foreach ($job in $jobsRunning) {
Write-Host "The job `"$($job.Name)`" is still running. It started at $($job.PSBeginTime)"
}
Sleep -s 3
} else {
$running = $false
}
if($timer.ElapsedMilliseconds -ge $acceptedWait){
$timer.Stop()
Write-Host "Accepted time was reached, stopping all jobs still pending." -BackgroundColor Red
$failed = #()
foreach($job in $jobsRunning){
$output = $job | Receive-Job
$failed += [PsCustomObject]#{
"ComputerName" = $job.Name;
"Output" = $output;
}
$job | Remove-Job -Force
$jobs.Remove($job)
}
$failed | Export-Csv .\pendingInstallations.csv -NoTypeInformation -Force
$running = $false
}
}while($running)
Write-host $bannerDone
$results = #()
foreach($job in $jobs){
$output = $job | Receive-Job
$results += [PsCustomObject]#{
"ComputerName" = $job.Name;
"Output" = $output;
}
}
$results | Export-Csv .\install.csv -NoTypeInformation -Force
#endregion
This script will trigger 10 jobs that only wait and return its names, then the jobs that got completed in the time that you set are consider correct and the ones that didn't are consider as pending, both groups get exported to a CSVfor review. You will need to replace the following to work as you intended:
Add $params = '/i <path to AcroPro.msi> LANG_LIST=en_US TRANSFORMS="1033.mst" /qb' in the SetUp region
Replace the declaration of $computers with $computers = Get-Content C:\Users\name\Desktop\flash.txt
Replace the body of Start-Job scriptblock with Invoke-command from thew first snippet of code in this answer.
you should end-up with something like:
.
.code
.
$params = '/i <path to AcroPro.msi> LANG_LIST=en_US TRANSFORMS="1033.mst" /qb'
#VARS TO SET
$MinutesToWait = 1
$computers = Get-Content C:\Users\name\Desktop\flash.txt
#endregion
#region ######## Main #######
#Start Jobs
$jobs = [System.Collections.ArrayList]::new()
foreach($computer in $computers){
$jobs.Add(
(Start-Job -Name $computer -ScriptBlock {
Param(
[Parameter(Mandatory=$true, Position=0)]
[String]$computer
)
Invoke-Command -ScriptBlock {
Param(
[Parameter(Mandatory=$true,Position=0)]
[String]$arguments
)
return Start-Process msiexec.exe -ArgumentList $arguments -Wait -PassThru
} -ComputerName $computer -ArgumentList $params
} -ArgumentList $computer)
) | Out-Null
}
.
. code
.
I know it looks like a complete mess, but it works.
Hope it helps.

PowerShell Jobs vs Start-Process

I made a little diagnostic script that I keep in my $profile. In collecting the CPU name I found that the command takes about 4 seconds (Get-WmiObject -Class Win32_Processor).Name. So I thought I'd try PowerShell Jobs and while I think they will be really good for long background jobs, if you just want to quickly grab a small piece of information in the background, the initialisation times are awkward (like 2-3 sec per job) so I thought I'd use Start-Process to dump values in temp files while the rest of my script runs. I think I'm doing this correctly, but if you run this function 3 or 4 times, you'll notice that CPU name is not populated.
• Is using Start-Process like this optimal, or does anyone have a quicker way to just start small jobs in the background in parallel? I know there is a .NET way of doing this (but it seems super-complex from what I've seen)?
• Do you know why my "wait for file to be created and be non-zero before accessing it" is failing so regularly?
function sys {
$System = get-wmiobject -class "Win32_ComputerSystem"
$Mem = [math]::Ceiling($System.TotalPhysicalMemory / 1024 / 1024 / 1024)
$wmi = gwmi -class Win32_OperatingSystem -computer "."
$LBTime = $wmi.ConvertToDateTime($wmi.Lastbootuptime)
[TimeSpan]$uptime = New-TimeSpan $LBTime $(get-date)
$s = "" ; if ($uptime.Days -ne 1) {$s = "s"}
$uptime_string = "$($uptime.days) day$s $($uptime.hours) hr $($uptime.minutes) min $($uptime.seconds) sec"
$temp_cpu = "$($env:TEMP)\ps_temp_cpu.txt"
$temp_cpu_cores = "$($env:TEMP)\ps_temp_cpu_cores.txt"
$temp_cpu_logical = "$($env:TEMP)\ps_temp_cpu_logical.txt"
rm -force $temp_cpu -EA silent ; rm -force $temp_cpu_cores -EA silent ; rm -force $temp_cpu_logical -EA silent
Start-Process -NoNewWindow -FilePath "powershell.exe" -ArgumentList "-NoLogo -NoProfile (Get-WmiObject -Class Win32_Processor).Name > $temp_cpu"
Start-Process -NoNewWindow -FilePath "powershell.exe" -ArgumentList "-NoLogo -NoProfile (Get-WmiObject -Class Win32_Processor).NumberOfCores > $temp_cpu_cores"
Start-Process -NoNewWindow -FilePath "powershell.exe" -ArgumentList "-NoLogo -NoProfile (Get-WmiObject -Class Win32_Processor).NumberOfLogicalProcessors > $temp_cpu_logical"
""
"Hostname: $($System.Name)"
"Domain: $($System.Domain)"
"PrimaryOwner: $($System.PrimaryOwnerName)"
"Make/Model: $($System.Manufacturer) ($($System.Model))" # "ComputerModel: $((Get-WmiObject -Class:Win32_ComputerSystem).Model)"
"SerialNumber: $((Get-WmiObject -Class:Win32_BIOS).SerialNumber)"
"PowerShell: $($PSVersionTable.PSVersion)"
"Windows Version: $($PSVersionTable.BuildVersion)"
"Windows ReleaseId: $((Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name 'ReleaseId').ReleaseId)"
"Display Card: $((Get-WmiObject -Class:Win32_VideoController).Name)"
"Display Driver: $((Get-WmiObject -Class:Win32_VideoController).DriverVersion)"
"Display ModelDesc: $((Get-WmiObject -Class:Win32_VideoController).VideoModeDescription)"
"Last Boot Time: $([Management.ManagementDateTimeConverter]::ToDateTime((Get-WmiObject Win32_OperatingSystem | select 'LastBootUpTime').LastBootUpTime))" # $(wmic OS get LastBootupTime)
"Uptime: $uptime_string"
# ipconfig | sls IPv4
Get-Netipaddress | where AddressFamily -eq IPv4 | select IPAddress,InterfaceIndex,InterfaceAlias | sort InterfaceIndex
# Get-PSDrive | sort -Descending Free | Format-Table
# https://stackoverflow.com/questions/37154375/display-disk-size-and-freespace-in-gb
# https://www.petri.com/checking-system-drive-free-space-with-wmi-and-powershell
# https://www.oxfordsbsguy.com/2017/02/08/powershell-how-to-check-for-drives-with-less-than-10gb-of-free-diskspace/
# Get-Volume | Where-Object {($_.SizeRemaining -lt 10000000000) -and ($_.DriveType -eq “FIXED”) -and ($_.FileSystemLabel -ne “System Reserved”)}
gwmi win32_logicaldisk | Format-Table DeviceId, VolumeName, #{n="Size(GB)";e={[math]::Round($_.Size/1GB,2)}},#{n="Free(GB)";e={[math]::Round($_.FreeSpace/1GB,2)}}
# Note: -EA silent on Get-Item otherwise get an error
while (!(Test-Path $temp_cpu)) { while ((Get-Item $temp_cpu -EA silent).length -eq 0kb) { Start-Sleep -Milliseconds 500 } }
"CPU: $(cat $temp_cpu)"
while (!(Test-Path $temp_cpu_cores)) { while ((Get-Item $temp_cpu_cores -EA silent).length -eq 0kb) { Start-Sleep -Milliseconds 500 } }
"CPU Cores: $(cat $temp_cpu_cores)"
while (!(Test-Path $temp_cpu_logical)) { while ((Get-Item $temp_cpu_logical -EA silent).length -eq 0kb) { Start-Sleep -Milliseconds 500 } }
"CPU Logical: $(cat $temp_cpu_logical)"
rm -force $temp_cpu -EA silent ; rm -force $temp_cpu_cores -EA silent ; rm -force $temp_cpu_logical -EA silent
"Memory: $(Get-CimInstance Win32_PhysicalMemory | Measure-Object -Property capacity -Sum | Foreach {"{0:N2}" -f ([math]::round(($_.Sum / 1GB),2))}) GB"
""
"Also note the 'Get-ComputerInfo' Cmdlet (has more info but slower to run)"
""
}
To run jobs in background in powershell, there are these 3 ways to go about it
1. Invoke-Command[3] -scriptblock { script } -asJob -computername localhost
2. Start-Job[2] -scriptblock { script }
3. Start-Process[1] powershell {script}
If you truly want to run things in the background with each job being independent of each other, you'll have to think about using the first or second option as neither of them require the output to be written to a file.
Invoke-Command starts a new session with the system and runs the job in a new instance.
Start-Job creates a new job in the background under a new powershell instance, takes a little more time to allocate the resources and start the process. Just like start-process, Start-Job will run the job in a separate powershell.exe instance.
Start-Process requires you to redirect the standard output to a file[1]. You have to rely on the performance of the disk and how fast your reads and writes are. You also have to ensure that no more than one thread is reading/writing to the output of this process.
Recommendation
I found Invoke-Command to be the fastest when running 100 concurrent jobs to get the processor info. This option does require you to provide -ComputerName which then requires you to be an admin to start a winrm Session with localhost. If you dont output the job information while creating the jobs, it does not take away any significant time.
Start-Job and Invoke-Command both took about a second to get the processor info and running 100 concurrent jobs to get the same thing took some overhead.
$x = 0..100 | Invoke-Command -computername localhost -scriptblock { script } -asJob
$x | % { $_ | wait-job | out-null }
$output = $x | % { $_ | Receive-Job}
# You can run measure-object, sort-object, etc as well
[1]Start-Process
RedirectStandardOutput: Specifies a file. This cmdlet sends the output generated by the process to a file that you specify. Enter the path and filename. By default, the output is displayed in the console.
[2]Start-Job
The Start-Job cmdlet starts a PowerShell background job on the local computer. ... A PowerShell background job runs a command without interacting with the current session.
[3]Invoke-Command
The Invoke-Command cmdlet runs commands on a local or remote computer and returns all output from the commands, including errors. ... To run a command in a background job, use the AsJob parameter

Counting the results of Invoke-Command

I have a job that goes off and checks for specified windows processes that have taken over 5 minutes of CPU time, across a range of servers.
The processes and servers are supplied via arrays, and looped through with some loops, this works nicely.
However, what I want to do is count how many results are found for each process as it loops through.
For the purpose of this example, the set variables would be
$seconds = 300
$server = "SERVER1"
$process = "notepad.exe"
And the command I run is as follows
$list = (Invoke-Command -ComputerName $server -ScriptBlock {
Param($Rseconds, $Rprocess)
Get-Process | Where {
($_.CPU -gt $Rseconds) -and
($_.Path -like "*$Rprocess”)
} | ForEach-Object {
$_.Kill()
}
} -ArgumentList $seconds, $process)
As far as killing the process, it works perfectly, and respects the values input, but what I can't get it to do is count how many of each process it killed
I've tried simply incrementing a counter within the ForEach-Object block, and tried sticking Measure-Object in various places to try and return a value, so I can call on something like
$list.Count
But nothing seems to work. It simply returns a blank value.
Found the answer to this
$listCount = (Invoke-Command -ComputerName $server -ScriptBlock { param($Rseconds,$Rprocess) Get-Process | Where { ($_.CPU -gt $Rseconds) -and ($_.Path -like "*$Rprocess”) } | Measure-Object } -ArgumentList $seconds,$process)
Had to add in the Measure-Object, but I was also being fooled by my own setup. I'd limited the server scope to one server, but I was checking the wrong one, so led myself down a blind alley, and seems I'd had the solution previously anyway
All working now
Use Stop-Process with the parameter -PassThru instead of calling Kill() in a loop.
$list = Invoke-Command -ComputerName $server -ScriptBlock {
Param($Rseconds, $Rprocess)
Get-Process | Where-Object {
$_.CPU -gt $Rseconds -and
$_.Path -like "*$Rprocess"
} | Stop-Process -Force -PassThru
} -ArgumentList $seconds, $process
If you want only the count returned instead of a list of process objects you could do something like this instead:
$list = Invoke-Command -ComputerName $server -ScriptBlock {
Param($Rseconds, $Rprocess)
$proc = Get-Process | Where-Object {
$_.CPU -gt $Rseconds -and
$_.Path -like "*$Rprocess"
}
$proc | Stop-Process -Force
$proc.Count
} -ArgumentList $seconds, $process

start-job Run command in parallel and output result as they arrive

I am trying to get specific KBXXXXXX existence on a list of servers , but once my script one server it takes time and return result and come back and then move to next one . this script works perfectly fine for me .
I want my script to kick off and get-hotfix as job and other process just to collect the results and display them.
$servers = gc .\list.txt
foreach ($server in $servers)
{
$isPatched = (Get-HotFix -ComputerName $server | where HotFixID -eq 'KBxxxxxxx') -ne $null
If ($isPatched)
{
write-host $server + "Exist">> .\patchlist.txt}
Else
{
Write-host $server +"Missing"
$server >> C:\output.txt
}
}
The objective it to make the list execute faster rather than running serially.
With Powershell V2 you can use jobs as in #Andy answer or also in further detail in this link Can Powershell Run Commands in Parallel?
With PowerShell V2 you may also want to check out this script http://gallery.technet.microsoft.com/scriptcenter/Foreach-Parallel-Parallel-a8f3d22b using runspaces
With PowerShell V3 you have the foreach -parallel option.
for example (NB Measure-Command is just there for timing so you could make a comparison)
Workflow Test-My-WF {
param([string[]]$servers)
foreach -parallel ($server in $servers) {
$isPatched = (Get-HotFix -ComputerName $server | where {$_.HotFixID -eq 'KB9s82018'}) -ne $null
If ($isPatched)
{
$server | Out-File -FilePath "c:\temp\_patchlist.txt" -Append
}
Else
{
$server | Out-File -FilePath "c:\temp\_output.txt" -Append
}
}
}
Measure-Command -Expression { Test-My-WF $servers }
For this use PowerShell jobs.
cmdlets:
Get-Job
Receive-Job
Remove-Job
Start-Job
Stop-Job
Wait-Job
Here's an untested example:
$check_hotfix = {
param ($server)
$is_patched = (Get-HotFix -ID 'KBxxxxxxx' -ComputerName $server) -ne $null
if ($is_patched) {
Write-Output ($server + " Exist")
} else {
Write-Output ($server + " Missing")
}
}
foreach ($server in $servers) {
Start-Job -ScriptBlock $check_hotfix -ArgumentList $server | Out-Null
}
Get-Job | Wait-Job | Receive-Job | Set-Content patchlist.txt
Rather than use jobs, use the ability to query multiple computer that's built into the cmdlet. Many of Microsoft's cmdlets, especially those used for system management, take an array of strings as the input for a -Computername parameter. Pass in your list of servers, and the cmdlet will query all of them. Most of the cmdlets that have this ability will query the servers in series, but Invoke-Command will do it in parallel.
I haven't tested this as I don't have Windows booted at the moment, but this should get you started (in sequence).
$servers = gc .\list.txt
$patchedServers = Get-HotFix -ComputerName $servers | where HotFixID -eq 'KBxxxxxxx'|select machinename
$unpatchedServers = compare-object -referenceobject $patchedServers -differenceobject $servers -PassThru
$unpatchedServers |out-file c:\missing.txt;
$patchedServers|out-file c:\patched.txt;
In parallel:
$servers = gc .\list.txt
$patchedServers = invoke-command -computername $servers -scriptblock {Get-HotFix | where HotFixID -eq 'KBxxxxxxx'}|select -expandproperty pscomputername |sort -unique
As before, I don't have the right version of Windows available at the moment to test the above & check the output but it's a starting point.