PWSH | Parallelize Invoke-VMScript - powershell

I'm trying to parallelize a portion of a bigger PWSH script, basically I need to cycle through a list of VMs and execute some commands on them, and to speed up the process, I need this to be executed in parallel:
#Utils.ps1 contains all the functions
.(".\src\utils.ps1")
# List of VMs on which we execute the space reclaim
$VMs = Get-Content .\vmnames.txt
# Check if directory ./Results exists, if not it will be created
CheckDirResults
# Check if dir temp exists, if not it will be created
CheckDirTemp
# Asks for vCenter credentials
GetVServerCred
# Asks for VM credentials
GetVMCred
# Connects to vCenter
ConnectVcenter
# Commands that will be executed on VMs on line 94 (Invoke-VMScript)
$InvokeScript = #'
#! /bin/bash
pwd
'#
foreach ($vm in $VMs) {
$vms = Get-VM -Name $vm
$vms | ForEach-Object -Parallel {
Write-Output "Testing on $_"
Invoke-VMScript -VM $_ -GuestCredential $VMCred -ScriptText $InvokeScript -Confirm:$false
}
}
I also tried to simply execute the Invoke directly on $VMs like this:
$VMs | ForEach-Object -Parallel {
Write-Output "Testing on $_"
Invoke-VMScript -VM $_ -GuestCredential $VMCred -ScriptText $commands
}
In both cases, the Invoke-VMScript can't either connect to the VMs or can't find the chunk of code to execute on the VMs.
Errors:
"test.ps1" 44L, 934C 36,0-1 81%
---- ---- ----
evl6800756.sys.ntt.eu 443 VSPHERE.LOCAL\Administrator
Testing on euczdrpoc03
Testing on euczdrpoc30
Invoke-VMScript:
Line |
3 | Invoke-VMScript -VM $_ -GuestCredential $VMCred -ScriptText $comma …
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| 1/17/2023 10:42:02 AM Invoke-VMScript You are not currently connected to any servers. Please connect first using a Connect cmdlet.
Invoke-VMScript:
Line |
3 | Invoke-VMScript -VM $_ -GuestCredential $VMCred -ScriptText $comma …
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| 1/17/2023 10:42:02 AM Invoke-VMScript Value cannot be found for the mandatory parameter ScriptText
Invoke-VMScript:
Line |
3 | Invoke-VMScript -VM $_ -GuestCredential $VMCred -ScriptText $comma …
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| 1/17/2023 10:42:02 AM Invoke-VMScript You are not currently connected to any servers. Please connect first using a Connect cmdlet.
Invoke-VMScript:
Line |
3 | Invoke-VMScript -VM $_ -GuestCredential $VMCred -ScriptText $comma …
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| 1/17/2023 10:42:02 AM Invoke-VMScript Value cannot be found for the mandatory parameter ScriptText

The $using: scope modifier inside a -Parallel script-block lets you refer to variables assigned a value outside that script-block. Examples in your question are not complete enough to say for certain, but hopefully this explains at least some of the difficulty.
So for example within the parallel block, try $using:commands instead of your $commands. This bit from reference doc could be more helpfully spelled out, IMHO.
Use the $using: keyword to pass variable references to the running
script.
That's demonstrated more clearly in one of the reference examples, in this blog post, and this answer.
Edit: sounds like the parallel runspaces do not inherit the vCenter connection. The open proposal for a -UseCurrentState switch to transfer the current runspace state to -Parallel loop iterations might help in your situation. Meanwhile could try passing -Server $using:DefaultVIServer to relevant commands inside the parallel block. LucD's comment that PowerCLI is not threadsafe may also be worth researching alongside the note in the reference doc:
The ForEach-Object -Parallel parameter set runs script blocks in parallel on separate process threads. The $using: keyword allows passing variable references from the cmdlet invocation thread to each running script block thread. Since the script blocks run in different threads, the object variables passed by reference must be used safely. Generally it is safe to read from referenced objects that don't change. But if the object state is being modified then you must used thread safe objects, such as .NET System.Collection.Concurrent types

I managed to do it this way:
# Commands that will be executed on VMs on line 94 (Invoke-VMScript)
$commands = #'
#! /bin/bash
hostname
'#
# Loads the VMs names from the file vmnames.txt
$vms = Get-Content ".\vmnames.txt"
# Uses the content of the file vmnames.txt to get the VM objects
$vmnames = Get-VM -Name $vms
# Executes the commands on the VMs
$vmnames | ForEach-Object -Parallel {
# Loads the variables in the parallel session
$commands = $using:commands
$VMCred = $using:VMCred
Invoke-VMScript -VM $_ -GuestCredential $VMCred -ScriptText $commands
}
For some reasons, piping directly $vms (so the content of the file to ForEach-Object -Parallel won't pass correctly the value (in this case the VM name), while using the Get-VM -Name $vms will do.
Then, once this is solved the ForEach-Object -Parallel will finally get the correct VM name assigned to $_.

Related

Powershell ForEach-Object output

More of a basic question but I can't seem to get the syntax correct. I am trying to go through a list and then output the Computer Name and BDE status with it.
Get-Content 'C:\Users\Test\Test\IT\Lists\LaptopList.txt' | ForEach-Object { write-host "***" | manage-bde -status }
Is there something I need to put where the *'s are so that it correlates with the computer/object and the status?
Thanks!
Is there something I need to put where the *'s are so that it correlates with the computer/object and the status?
Yes! You're looking for $_:
Get-Content 'C:\Users\Test\Test\IT\Lists\LaptopList.txt' | ForEach-Object {
Write-Host $_
}
As the name indicates, Write-Host writes output directly to the host application - in the case of powershell.exe it writes directly to the screen buffer - which means it doesn't output anything to downstream cmdlets in a pipeline statement like the one you've constructed.
You'll therefore want two separate statements:
Get-Content 'C:\Users\Test\Test\IT\Lists\LaptopList.txt' | ForEach-Object {
Write-Host $_
manage-bde -status
}
Now, manage-bde is not a PowerShell cmdlet - it's a windows executable, and it doesn't support managing remote computers.
So we need something in PowerShell that can run manage-bde on the remote machine - my choice would be Invoke-Command:
Get-Content 'C:\Users\Test\Test\IT\Lists\LaptopList.txt' | ForEach-Object {
Write-Host "Remoting into $_ to fetch BitLocker Drive Encryption status
Invoke-Command -ComputerName $_ -ScriptBlock { manage-bde -status }
}
Here, we instruct Invoke-Command to connect to the computer with whatever name is currently assigned to $_, and then execute manage-bde -status on the remote machine and return the resulting output, if any. Assuming that WinRM/PowerShellRemoting is configured on the remote machines, and the user executing this code locally is a domain account with local admin privileges on the remote computers, this will work as-is.
Further reading:
about_Remote
about_Remote_Requirements
about_Remote_Troubleshooting

run two processes and wait for both to finish in powershell [duplicate]

I have been given the task to write a PS script that will, from a list of machines in a text file:
Output the IP address of the machine
Get the version of the SCCM client on the machine
Produce a GPResult HTMl file
OR
Indicate that the machine is offline
With a final stipulation of running the script in the background (Job)
I have the scriptblock that will do all of these things, and even have the output formatted like I want. What I cannot seem to do, is get the scriptblock to call the source file from within the same directory as the script. I realize that I could simply hard-code the directories, but I want to be able to run this on any machine, in any directory, as I will need to use the script in multiple locations.
Any suggestions?
Code is as follows (Note: I am in the middle of trying stuff I gathered from other articles, so it has a fragment or two in it [most recent attempt was to specify working directory], but the core code is still there. I also had the idea to declare the scriptblock first, like you do with variables in other programming languages, but more for readability than anything else):
# List of commands to process in job
$ScrptBlk = {
param($wrkngdir)
Get-Content Hostnames.txt | ForEach-Object {
# Check to see if Host is online
IF ( Test-Connection $_ -count 1 -Quiet) {
# Get IP address, extracting only IP value
$addr = (test-connection $_ -count 1).IPV4Address
# Get SCCM version
$sccm = (Get-WmiObject -NameSpace Root\CCM -Class Sms_Client).ClientVersion
# Generate GPResult HTML file
Get-GPResultantSetOfPolicy -computer $_.name -reporttype HTML -path ".\GPRes\$_ GPResults.html"}
ELSE {
$addr = "Offline"
$sccm = " "}
$tbl = New-Object psobject -Property #{
Computername = $_
IPV4Address = $addr
SCCM_Version = $sccm}}}
# Create (or clear) output file
Echo "" > OnlineCheckResults.txt
# Create subdirectory, if it does not exist
IF (-Not (Get-Item .\GPRes)) { New-Item -ItemType dir ".\GPRes" }
# Get current working directory
$wrkngdir = $PSScriptRoot
# Execute script
Start-Job -name "OnlineCheck" -ScriptBlock $ScrptBlk -ArgumentList $wrkngdir
# Let job run
Wait-Job OnlineCheck
# Get results of job
$results = Receive-Job OnlineCheck
# Output results to file
$results >> OnlineCheckResults.txt | FT Computername,IPV4Address,SCCM_Version
I appreciate any help you may have to offer.
Cheers.
~DavidM~
EDIT
Thanks for all the help. Setting the working directory works, but I am now getting a new error. It has no line reference, so I am not sure where the problem might be. New code below. I have moved the sriptblock to the bottom, so it is separate from the rest of the code. I thought that might be a bit tidier. I do apologize for my earlier code formatting. I will attempt to do better with the new example.
# Store working directory
$getwkdir = $PWD.Path
# Create (or clear) output file
Write-Output "" > OnlineCheckResults.txt
# Create subdirectory, if it does not exist. Delete and recreate if it does
IF (Get-Item .\GPRes) {
Remove-Item -ItemType dir "GPRes"
New-Item -ItemType dir "GPRes"}
ELSE{
New-Item -ItemType dir "GPRes"}
# Start the job
Start-Job -name "OnlineCheck" -ScriptBlock $ScrptBlk -ArgumentList $getwkdir
# Let job run
Wait-Job OnlineCheck
# Get results of job
$results = Receive-Job OnlineCheck
# Output results to file
$results >> OnlineCheckResults.txt | FT Computername,IPV4Address,SCCM_Version
$ScrptBlk = {
param($wrkngdir)
Set-Location $wrkngdir
Get-Content Hostnames.txt | ForEach-Object {
IF ( Test-Connection $_ -count 1 -Quiet) {
# Get IP address, extracting only IP value
$addr = (test-connection $_ -count 1).IPV4Address
# Get SCCM version
$sccm = (Get-WmiObject -NameSpace Root\CCM -Class Sms_Client).ClientVersion
Get-GPResultantSetOfPolicy -computer $_.name -reporttype HTML -path ".\GPRes\$_ GPResults.html"}
ELSE {
$addr = "Offline"
$sccm = " "}
$tbl = New-Object psobject -Property #{
Computername = $_
IPV4Address = $addr
SCCM_Version = $sccm}}}
Error text:
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.
+ CategoryInfo : InvalidData: (:) [Test-Connection], ParameterBindingValidationException
+ FullyQualifiedErrorId : ParameterArgumentValidationError,Microsoft.PowerShell.Commands.TestConnectionCommand
+ PSComputerName : localhost
As Theo observes, you're on the right track by trying to pass the desired working directory to the script block via -ArgumentList $wrkngdir, but you're then not using that argument inside your script block.
All it takes is to use Set-Location at the start of your script block to switch to the working directory that was passed:
$ScrptBlk = {
param($wrkngdir)
# Change to the specified working dir.
Set-Location $wrkngdir
# ... Get-Content Hostnames.txt | ...
}
# Start the job and pass the directory in which this script is located as the working dir.
Start-Job -name "OnlineCheck" -ScriptBlock $ScrptBlk -ArgumentList $PSScriptRoot
In PSv3+, you can simplify the solution by using the $using: scope, which allows you to reference variables in the caller's scope directly; here's a simplified example, which you can run directly from the prompt (I'm using $PWD as the desired working dir., because $PSScriptRoot isn't defined at the prompt (in the global scope)):
Start-Job -ScriptBlock { Set-Location $using:PWD; Get-Location } |
Receive-Job -Wait -AutoRemove
If you invoke the above command from, say, C:\tmp, the output will reflect that path too, proving that the background job ran in the same working directory as the caller.
Working directories in PowerShell background jobs:
Before PowerShell 7.0, starting background jobs with Start-Job uses the directory returned by [environment]::GetFolderPath('MyDocuments') as the initial working directory, which on Windows is typically $HOME\Documents, whereas it is just $HOME on Unix-like platforms (in PowerShell Core).
Setting the working dir. for the background job via Start-Job's -InitializationScript script-block argument via a $using: reference - e.g., Start-Job -InitializationScript { $using:PWD } { ... } should work, but doesn't in Windows PowerShell v5.1 / PowerShell [Core] 6.x, due to a bug (the bug is still present in PowerShell 7.0, but there you can use -WorkingDirectory).
In PowerShell (Core) 7+, Start-Job now sensibly defaults to the caller's working directory and also supports a -WorkingDirectory parameter to simplify specifying a working directory.
In PowerShell (Core) 6+ you can alternatively start background jobs with a post-positional & - the same way that POSIX-like shells such as bash do - in which case the caller's working directory is inherited; e.g.:
# PS Core only:
# Outputs the caller's working dir., proving that the background job
# inherited the caller's working dir.
(Get-Location &) | Receive-Job -Wait -AutoRemove
If I understand correctly, I think that the issue you are having is because the working directory path is different inside the execution of the Script Block. This commonly happens when you execute scripts from Scheduled tasks or pass scripts to powershell.exe
To prove this, let's do a simple PowerShell code:
#Change current directory to the root of C: illustrate what's going on
cd C:\
Get-Location
Path
----
C:\
#Execute Script Block
$ScriptBlock = { Get-Location }
$Job = Start-Job -ScriptBlock $ScriptBlock
Receive-Job $Job
Path
----
C:\Users\HAL9256\Documents
As you can see the current path inside the execution of the script block is different than where you executed it. I have also seen inside of Scheduled tasks, paths like C:\Windows\System32 .
Since you are trying to reference everything by relative paths inside the script block, it won't find anything. One solution is to use the passed parameter to change your working directory to something known first.
Also, I would use $PWD.Path to get the current working directory instead of $PSScriptRoot as $PSScriptRoot is empty if you run the code from the console.

Unable to use the "ForEach-Object -Parallel" in a directory with a name containing "["

Using the ForEach-Object -Parallel cmdlet in a directory with a name containing "[" will return a WildcardPatternException. Remove -Parallel, it will run successfully.
I created a few directories and ran the following commands.1..5 | ForEach-Object -Parallel {Write-Host $_}A difference of a return for each ran directory is as follows.
PS D:\[example> 1..5 | ForEach-Object -Parallel {Write-Host $_}
WildcardPatternException will be returned.
PS D:\[]example> 1..5 | ForEach-Object -Parallel {Write-Host $_}
PS D:\[ex]ample> 1..5 | ForEach-Object -Parallel {Write-Host $_}
ItemNotFoundException will be returned.
PS D:\[e]xample> 1..5 | ForEach-Object -Parallel {Write-Host $_}
PS D:\]example> 1..5 | ForEach-Object -Parallel {Write-Host $_}
Ran successfully and 5 values are returned.
Is there a way to resolve this without renaming directories?
As mentioned in the comments, this is a bug.
ForEach-Object -Parallel works by offloading execution of the scriptblock to multiple background runspaces via something called a TaskPool.
In order to mimic the callers environment, PowerShell configures the background runspaces to resemble the default runspace, including setting the current provider lotation to whatever the callers is.
Unfortunately, the internal API for setting the current location defaults to expand wildcards, the equivalent of defaulting to:
Set-Location -Path 'D:\[]example'
instead of
Set-Location -LiteralPath 'D:\[]example'
This has now been patched (both for ForEach-Object -Parallel and Start-PSThreadJob), expect both to ship with version 7.1 later this year

Multithreading foreach loop when using invoke-vmscript in powershell/powercli

I have a script that modifies network info of certain VMs ,however it run sequentially . Since there are hundred of VMs , I want to all VMs to execute the command simultaneously in order to save time . Also how do I add invoke-vmscript to the scriptblock with external variables. I did try few things but doesnt seem to working well. Script is run on a system using powercli 10 on linux and no additional modules are allowed to be installed
Below is the code , any help would be much appreciated
$test=get-vm -name testvm* |sort-object
$fip='192.168.10.41'
$f=$fip.Split(".")|select -first 3
$s=[system.String]::join(".",$f)
$l=$fip.Split(".")|select -last 1
foreach ($vm in $test) {
Write-Host "$vm $s.$l"
do {start-sleep -s 2; write-host " $vm not ready yet" ; $tstatus=(get-vm
$vm).extensiondata.Guest.ToolsStatus;}while($tstatus -ne "toolsOk");
invoke-vmscript -ScriptText "sed -i '14,20 d' /etc/network/interfaces" -
ScriptType bash -VM $vm -GuestUser admin -GuestPassword Welcome!}
$ipscript = "sed -i -e 's/address *.*.*.*/address $s.$l/g'
/etc/network/interfaces"
write-host " Adding IP "
invoke-vmscript -ScriptText $ipscript -ScriptType bash -VM $vm -GuestUser
$GuestUserName admin -GuestPassword Welcome!
Start-Sleep -s 2
Restart-VM -VM $vm -RunAsync -Confirm:$false
$l=[int]$l+1
}

VMware PowerCLI Variables in ScriptText

Hello I'm having a bit of problem with the Invoke-VMScript cmdlet
I have made a script that creates a virtual Windows 7 machine and then some powershell script executes on the machine for example renaming the computer to the correct name.
But if I run
Invoke-VMScript -ScriptText {(Get-Wmiobject -Class Win32_ComputerSystem).Rename($strName)}
The $strName variable doesn't get resolved, anyone have any idea on how to do this?
Your haven't correctly scoped your variable. Here's a simple experiment you can try in your console:
PS C:\> $test = "Resolve!"
PS C:\> $test
Resolve!
# This scriptblock will not resolve the variable.
PS C:\> {$test}
$test
# This scriptblock will resolve the variable.
PS C:\> [scriptblock]::Create($test)
Resolve!
Invoke-VMScript's documentation suggests you should pass -ScriptText as a string instead of a ScriptBlock. So in this case, we can mimic example 2:
$string = "(Get-Wmiobject -Class Win32_ComputerSystem).Rename($strName)"
Invoke-VMScript -ScriptText $string
Variables enclosed by " " will be resolved.
I don't have a guest handy for renaming, but following LucD, here's a working example using variables to build up input for scriptText:
$svcName = "vmtools"
$guestScript = 'get-wmiObject win32_service | where {$_.name -eq "' + $svcName + '"}'
Invoke-VMScript -vm myVMname -scriptText $guestScript
#Rename Computer command line script
$renamecomputer = "wmic path win32_computersystem where ""Name='%computername%'"" CALL rename name='$VM'"
#Send command line script to GuestOS
Invoke-VMScript -VM $VM -GuestUser $GU -GuestPassword $GP -ScriptType Bat -ScriptText $renamecomputer
This is what i use for my renaming script.
Works well
I got this working:
Invoke-VMScript -VM $vm -ScriptText "(Get-WmiObject win32_computersystem).rename(""${vmName}"")"