Last week I finally decided to rollup my sleeves and attempt to refactor some of my more recent Windows PowerShell 5.1 code to work with PowerShell Core 6.X (on Windows). My goal was to use the WindowsCompatibility Module as efficiently as possible so that I really didn’t have to touch the majority of my existing code. The experience was relatively painless, but I still wanted to share some lessons learned as well as a way to make (most of) your existing code compatible with PowerShell Core by adding only two lines towards the beginning of your script/function/Module.
WindowsCompatibility Module On GitHub:
https://github.com/PowerShell/WindowsCompatibility
WindowsCompatibility Module On PSGallery:
https://www.powershellgallery.com/packages/WindowsCompatibility
Get to the (Bullet) Point(s)
First and foremost, I want reiterate that everything in this post applies to PowerShell Core on Windows, not Linux.
(SIDE NOTE: There is actually a relatively easy way of getting the WindowsCompatibility Module to work with PowerShell Core on Linux - effectively giving you access to Windows PowerShell 5.1 on Linux - but I suspect the PowerShell Team and contributors will be streamlining this in the future, so I’m not going to touch on it in this post).
That being said, here are some lessons learned:
- Install and import all required Modules explicitly at the beginning of your script/function/Module - including Modules that PowerShell normally loads for you automatically when you use an associated cmdlet.
- After you import a Module using the WindowsCompatibility Module’s
Import-WinModule
cmdlet, make sure all of the commands you expect to be available are, in fact, available - Pay extra attention to your use of type accelerators. Sometimes the underlying class doesn’t exist in PowerShell Core…sometimes the type accelerator itself just isn’t set by default like it is in Windows PowerShell 5.1.
- Be very careful when using objects that come from Windows PowerShell 5.1 via the WindowsCapability Module’s implicit remoting. Any property that is (itself) an object will not be represented as expected (exceptions being string, bool, and int, which are expressed as expected). This is due to serialization/deserialization over the implicit remoting session.
- Be very careful when using
Add-Type
. Sometimes your C# can compile in PowerShell Core, sometimes it can’t. - Whenever you use
Invoke-WinCommand
, make sure you always use the-ComputerName
parameter even if it is justlocalhost
. There are some situations whereInvoke-WinCommand
complains about not having this parameter set explicitly, eventhough it shouldn’t be necessary. I would open an issue on GitHub, but I can’t recreate it consistently. - Don’t use
Start-Job
- Setting up an equivalent WindowsCompatibility environment within the separate process is a bug factory…Use my New-Runspace function instead:
Helper Functions to the Rescue
Having stepped on all of the aforementioned landmines, I thought it would be a good idea to create a way to quickly gut-check existing Windows PowerShell 5.1 code in PowerShell Core before moving forward with any refactors. So, I created three helper functions that will allow you to quickly make a reasonable attempt at running your existing Windows PowerShell 5.1 code in PowerShell Core with very little effort.
My three PSCompatibility Functions are: GetModuleDependencies
, InvokePSCompatibility
, and InvokeModuleDependencies
.
(NOTE: I didn’t use dashes in the function names because I generally use them as Private Functions, and I’m a big fan of being able to quickly differentiate between Public and Private functions by simply looking for a dash.)
The GetModuleDependencies
function finds all cmdlets/functions in your script/function/Module and maps them to their associated locally-available Module. The InvokePSCompatibility
function contains logic that determines which Modules to install/import and (of equal importance) which Modules not to install/import. The InvokeModuleDependencies
function contains logic that determines how Modules should be installed/imported: if you’re within PowerShell Core, InvokePSCompatibility
will be used; if you’re within Windows PowerShell 5.1, Modules will be installed/imported using traditional methods.
In the end, the only function that you’ll need to actively use is InvokeModuleDependencies
. Also, under most circumstances, you’ll probably only need to add two lines of code towards the beginning of your existing script/function/Module in order to make it compatible with PowerShell Core.
InvokeModuleDependencies Usage
Before we jump into the below examples, download PSCompatibilityFunctions.ps1
from my GitHub repo:
Download using PowerShell (5.1 or Core 6.X) via:
$DownloadUri = "https://raw.githubusercontent.com/pldmgg/misc-powershell/master/MyFunctions/PowerShellCore_Compatible/PSCompatibilityFunctions.ps1"
Invoke-WebRequest -Uri $DownloadUri -OutFile "$HOME\Downloads\PSCompatibilityFunctions.ps1"
The First (Somewhat Contrived) Example
Launch the latest version of PowerShell Core and try the following…
PS C:\Users\zeroadmin> Set-Content -Path "$HOME\Get-NetworkInfo.ps1" -Value @'
function Get-NetworkInfo {
[CmdletBinding()]
Param (
[Parameter(Mandatory=$False)]
[switch]$IPv4Only = $True
)
if ($IPv4Only) {
Get-NetIPAddress -AddressFamily IPv4 | Where-Object {$_.InterfaceAlias -notmatch "Loopback"}
}
else {
Get-NetIPAddress | Where-Object {$_.InterfaceAlias -notmatch "Loopback"}
}
}
'@
PS C:\Users\zeroadmin> . "$HOME\Get-NetworkInfo.ps1"
PS C:\Users\zeroadmin> Get-NetworkInfo
Get-NetIPAddress : The term 'Get-NetIPAddress' is not recognized as the name of a cmdlet, function, script file, or operable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
At V:\powershell\Testing\PSCompatTest.ps1:9 char:9
+ Get-NetIPAddress -AddressFamily IPv4
+ ~~~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (Get-NetIPAddress:String) [], CommandNotFoundException
+ FullyQualifiedErrorId : CommandNotFoundException
As you can see, PowerShell Core doesn’t recognize the Networking Cmdlets that are part of the (built-in) Windows PowerShell 5.1 NetTCPIP Module. I could refactor this function by…
- Adding a check for the PowerShell version
- Using if/then statements that determine whether to install/import the WindowsCompatibility Module
- Using the cmdlets
Import-WinModule
and/orInvoke-WinCommand
as appropriate
…but this get’s really annoying very quickly if you’re going through a larger code base.
So instead, let’s just add a couple of lines at the beginning of Get-NetworkInfo.ps1
and call it a day…
PS C:\Users\zeroadmin> Set-Content -Path "$HOME\Get-NetworkInfo.ps1" -Value @'
. "$HOME\Downloads\PSCompatibilityFunctions.ps1"
$ModuleDependenciesMap = InvokeModuleDependencies
function Get-NetworkInfo {
[CmdletBinding()]
Param (
[Parameter(Mandatory=$False)]
[switch]$IPv4Only = $True
)
if ($IPv4Only) {
Get-NetIPAddress -AddressFamily IPv4 | Where-Object {$_.InterfaceAlias -notmatch "Loopback"}
}
else {
Get-NetIPAddress | Where-Object {$_.InterfaceAlias -notmatch "Loopback"}
}
}
'@
PS C:\Users\zeroadmin> . "$HOME\Get-NetworkInfo.ps1"
PS C:\Users\zeroadmin> Get-NetworkInfo
IPAddress : 192.168.2.46
InterfaceIndex : 2
InterfaceAlias : Ethernet
AddressFamily : 2
Type : 1
PrefixLength : 24
PrefixOrigin : 3
SuffixOrigin : 3
AddressState : 4
ValidLifetime :
PreferredLifetime :
SkipAsSource : False
PolicyStore : 1
Hooray! The Get-NetworkInfo
function is now compatible with both Windows PowerShell 5.1 AND PowerShell Core 6.X!
Even though the above example involves dot sourcing a .ps1 file that contains the Get-NetworkInfo
function, it’s important to note that you can also just use InvokeModuleDependencies
directly within a function loaded in memory. In other words, we could simply do the following directly in PowerShell Core and get the same result:
PS C:\Users\zeroadmin> function Get-NetworkInfo {
[CmdletBinding()]
Param (
[Parameter(Mandatory=$False)]
[switch]$IPv4Only = $True
)
. "$HOME\Downloads\PSCompatibilityFunctions.ps1"
$ModuleDependenciesMap = InvokeModuleDependencies
if ($IPv4Only) {
Get-NetIPAddress -AddressFamily IPv4 | Where-Object {$_.InterfaceAlias -notmatch "Loopback"}
}
else {
Get-NetIPAddress | Where-Object {$_.InterfaceAlias -notmatch "Loopback"}
}
}
PS C:\Users\zeroadmin> Get-NetworkInfo
IPAddress : 192.168.2.46
InterfaceIndex : 2
InterfaceAlias : Ethernet
AddressFamily : 2
Type : 1
PrefixLength : 24
PrefixOrigin : 3
SuffixOrigin : 3
AddressState : 4
ValidLifetime :
PreferredLifetime :
SkipAsSource : False
PolicyStore : 1
Using InvokeModuleDependencies Within in a Module
You can also use InvokeModuleDependencies
within a Module’s .psm1 file to make your entire Module compatible with PowerShell Core! In this situation, best practice would be to add GetModuleDependencies
, InvokePSCompatibility
, and InvokeModuleDependencies
as Private functions within your Module and then do something like the following at towards the beginning of your .psm1 file…
# Get public and private function definition files.
[array]$Public = Get-ChildItem -Path "$PSScriptRoot\Public\*.ps1" -ErrorAction SilentlyContinue
[array]$Private = Get-ChildItem -Path "$PSScriptRoot\Private\*.ps1" -ErrorAction SilentlyContinue
$ThisModule = $(Get-Item $PSCommandPath).BaseName
# Dot source the Private functions
foreach ($import in $Private) {
try {
. $import.FullName
}
catch {
Write-Error -Message "Failed to import function $($import.FullName): $_"
}
}
if (Test-Path "$PSScriptRoot\module.requirements.psd1") {
$ModuleManifestData = Import-PowerShellDataFile "$PSScriptRoot\module.requirements.psd1"
$ModulesToInstallAndImport = $ModuleManifestData.Keys | Where-Object {$_ -ne "PSDependOptions"}
}
$InvModDepSplatParams = @{
RequiredModules = $ModulesToInstallAndImport
InstallModulesNotAvailableLocally = $True
}
$ModuleDependenciesMap = InvokeModuleDependencies @InvModDepSplatParams
# Public Functions
...<Truncated>...
IMPORTANT NOTE: Unfortunately, depending on the number of Module dependencies in your custom Module, using InvokeModuleDependencies
will significantly increase the amount of time it takes to import your custom Module.
Additional Examples
If your script/function/Module requires any Modules that are NOT already installed locally, then you can use the -RequiredModules
parameter.
IMPORTANT NOTE: If you’re not sure if a particular Module is already installed/available locally, add it to the -RequiredModules
list and use the -InstallModulesNotAvailableLocally
switch. Since InvokeModuleDependencies
checks local availability before attempting to download/install external Modules, there’s no detriment to adding it to the -RequiredModules
string array.
PS C:\Users\zeroadmin> function New-LabBuilderLab {
Param (
[Parameter(Mandatory=$True)]
[string]$ConfigPath,
[Parameter(Mandatory=$True)]
[string]$LabPath,
[Parameter(Mandatory=$True)]
[string]$LabName
)
. "$HOME\Downloads\PSCompatibilityFunctions.ps1"
$ModuleDependenciesMap = InvokeModuleDependencies -RequiredModules @("LabBuilder") -InstallModulesNotAvailableLocally
if (! $(Test-Path $LabPath)) {
$null = New-Item -ItemType Directory -Path $LabPath
}
New-Lab -ConfigPath $ConfigPath -LabPath $LabPath -Name $LabName
}
PS C:\Users\zeroadmin> New-LabBuilderLab -ConfigPath "$HOME\NewLab\LabConfig" -LabPath "$HOME\NewLab" -LabName Test
Directory: C:\Users\zeroadmin\NewLab
Mode LastWriteTime Length Name
---- ------------- ------ ----
d----- 6/26/2018 2:20 PM 1 ISOFiles
d----- 6/26/2018 2:20 PM 1 VHDFiles
RunspaceId : d40d6471-7e0e-4a3c-9ac4-407e2cf3f091
xml : version="1.0" encoding="utf-8"
labbuilderconfig : labbuilderconfig
Gotchas
Unfortunately, not all Modules can be imported cleanly using the WindowsCompatibility Module’s Import-WinModule
cmdlet. Sometimes, you will have to refactor your code directly using the Invoke-WinCommand
cmdlet.
PS C:\Users\zeroadmin> function Get-Permissions {
[CmdletBinding()]
Param (
[Parameter(Mandatory=$False)]
[string]$Account = $(whoami)
)
. "$HOME\Downloads\PSCompatibilityFunctions.ps1"
$ModuleDependenciesMap = InvokeModuleDependencies -RequiredModules @("NTFSSecurity")
Get-NTFSAccess -Path $(Get-Location).Path -Account $Account
}
PS C:\Users\zeroadmin> Get-Permissions
WARNING: The following Modules were not able to be loaded via implicit remoting:
NTFSSecurity
WARNING: All code within 'Get-Permissions' that uses these Modules must be refactored like:
Invoke-WinCommand -ComputerName localhost -ScriptBlock {
<existing code>
}
WARNING: 'Get-Permissions' will probably *not* work within PowerShell Core!
Get-NTFSAccess : The term 'Get-NTFSAccess' is not recognized as the name of a cmdlet, function, script file, or operable program.
Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
At V:\powershell\Testing\Get-Permissions.ps1:11 char:5
+ Get-NTFSAccess -Path $(Get-Location).Path -Account $Account
+ ~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (Get-NTFSAccess:String) [], CommandNotFoundException
+ FullyQualifiedErrorId : CommandNotFoundException
The above example can be refactored by simply doing…
function Get-Permissions {
[CmdletBinding()]
Param (
[Parameter(Mandatory=$False)]
[string]$Path
)
. "$HOME\Downloads\PSCompatibilityFunctions.ps1"
$ModuleDependenciesMap = InvokeModuleDependencies -RequiredModules @("NTFSSecurity") -InstallModulesNotAvailableLocally
if (!$Path) {
$Path = $(Get-Location).Path
}
Invoke-WinCommand -ComputerName localhost -ScriptBlock {
Get-NTFSAccess -Path $args[0]
} -ArgumentList $Path
}
PS C:\Users\zeroadmin> Get-Permissions -Path $(Get-Location).Path -WarningAction SilentlyContinue
AccountType :
PSComputerName : localhost
RunspaceId : 7bbff6b4-ff3a-44f3-8dbe-175511f1138f
Name : zeroadmin
FullName : C:\Users\zeroadmin
InheritanceEnabled : False
InheritedFrom :
...<Truncated>...
And there you have it! Hope this can help some folks! Let me know what you think on Reddit or on Twitter.