Cross Platform Scripting

When Microsoft moved PowerShell to the open-source world a few years ago, it opened up an entirely new world of scripting possibilities. Microsoft wants you to be able to manage anything from anywhere on whatever platform you need. Of course, you’ll want to build scripts and tools to use in this new world. And even if you aren’t in a situation now that requires cross-platform management, you never know what’s gonna happen tomorrow. And frankly, some of the things you need to keep in mind when it comes to cross-platform scripting can make you a better scripter for Windows PowerShell.

What do we mean when we say “cross-platform”? We mean that you can develop a tool that can be used on any PowerShell-supported platform and ideally, can manage or work with any platform. Instead of writing one tool to run on Linux and another to run on Windows, you write one tool that can run on both. Don’t build multiple tools to manage remote servers depending on the target operating system. Write one command that you can use regardless of the OS. To meet these requirements, there are a few things we want you to keep in mind. We are under no illusion that everything you want to do cross-platform will work. It won’t. There will be situations where a true cross-platform solution simply doesn’t work.

Know Your OS

When you are building something that can potentially be used cross-platform you need to think about the operating system where your code will be running. As cool as PowerShell 7 is, not every feature or command is supported on every operating system. You don’t need to be an expert-level Linux engineer, but you do need to understand some basics. For example, Linux doesn’t have the same concept of services as we understand them in the Windows world, so there is no Get-Service command in PowerShell 7 on non-Windows systems. Likewise, Linux doesn’t have WMI or CIM so you won’t find Get-CimInstance. Nor could you use Get-CimInstance to target a remote Linux machine. The OS doesn’t support these features so they aren’t available.

What this means is that you need to know what OS your code is running on and there are some things you can do to make your life easier.

State Your Requirements

First off, if you are writing a PowerShell function that uses PowerShell 7 features, such as the new ternary operator, you need to make sure the person running your script is using PowerShell 7. This means adding a #requires statement at the top of your file:

#requires -version 7.0

Honestly, this is something you should have been doing all along but you absolutely need it now. Because PowerShell 7 features and commands can also change between versions, you should be as specific as possible.

#requires -version 7.4

If you need specific modules that might be Windows-specific, state that requirement as well.

#requires -modules CIMCmdlets

If someone runs this on a Linux box they’ll get an error like:

The script 'Get-Miracle.ps1' cannot be run because the following modules that are sp\
ecified by the "#requires" statements of the script are missing: CimCmdlets.

If you are building a module, in the manifest you can use this setting to set requirements.

# Supported PSEditions
CompatiblePSEditions = @('Desktop','Core')

Desktop means Windows PowerShell. Core means the open-source and cross-platform version of PowerShell, regardless of operating system. This can be a little tricky. Because even though Get-CimInstance doesn’t work on a Mac in PowerShell 7, it will work just fine on a Windows desktop running PowerShell 7. Still, this high-level compatibility setting should be set. Delete whatever isn’t supported.

Testing Variables

Even though the Core setting is potentially problematic, there are some new automatic variables you can use in your code, to validate and test.

  • PSEdition - On PowerShell 7, this will have a value of Core. On Windows PowerShell, it will return Desktop. Look familiar?
  • IsWindows - A boolean value indicating if you are running Windows. Requires PowerShell 7.
  • IsLinux - A boolean value indicating if you are running Linux. Requires PowerShell 7.
  • IsMac - A boolean value indicating if you are running MacOS. Requires PowerShell 7.
  • IsCoreCLR - A boolean value indicating if you are running .NET Core which most likely means you are running PowerShell 7. This variable isn’t defined in Windows PowerShell.

And of course, don’t forget $PSVersionTable.

PS C:\> $PSVersionTable

Name                           Value
----                           -----
PSVersion                      7.4.2
PSEdition                      Core
GitCommitId                    7.4.2
OS                             Microsoft Windows 10.0.22631
Platform                       Win32NT
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1
WSManStackVersion              3.0

Which can vary by platform.

PS /home/jeff> $PSVersionTable

Name                           Value
----                           -----
PSVersion                      7.4.2
PSEdition                      Core
GitCommitId                    7.4.2
OS                             Ubuntu 22.04.4 LTS
Platform                       Unix
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0…}
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1
WSManStackVersion              3.0

It is also different on Windows PowerShell.

PS C:\> $PSVersionTable

Name                           Value
----                           -----
PSVersion                      5.1.22621.2506
PSEdition                      Desktop
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0...}
BuildVersion                   10.0.22621.2506
CLRVersion                     4.0.30319.42000
WSManStackVersion              3.0
PSRemotingProtocolVersion      2.3
SerializationVersion           1.1.0.1

These are all potentially useful items you can use to build If constructs or even dynamic parameters.

Environment Variables

One of the major challenges in cross-platform scripting is breaking out of the old way of doing things. Here’s a great example using a common parameter definition you might see in Windows PowerShell.

[string[]]$Computername = $env:ComputerName

This sets the %COMPUTERNAME% environment variable as the default value for $Computername. This will work just fine on Windows PowerShell and even PowerShell 7 running on Windows. But non-Windows platforms have different environment variables and this will fail. Instead, you can use a .NET code snippet to get the same information.

[environment]::MachineName

This is also true with $env:UserName which can be replaced with [environment]::UserName. If you are used to referencing environment variables, you’ll need to add a step to verify they are defined or begin using .NET alternatives.

Paths

PowerShell has always never cared about the direction of slashes in paths. You can run Test-Path c:\windows or Test-Path c:/windows. This is even true on non-Windows systems. You can use test-path /etc/ssh or test-path \etc\ssh. But you really should be careful. Many of you probably have used or seen code like this:

$file = "$foo\child\file.dat"

There’s a good chance it will work cross-platform. But the better approach, which you should be using anyway, is to use the path cmdlets like Join-Path. Here’s a sample.

Export-Data
Function Export-Data {
  [cmdletbinding()]
  Param(
    [ValidateScript({ Test-Path $_ })]
    [string]$Path = '.'
  )

  $time = Get-Date -Format FileDate
  $file = "$($time)_export.json"
  $ExportPath = Join-Path -Path (Convert-Path $Path) -ChildPath $file
  Write-Verbose "Exporting data to $exportPath"

  # code ...
}

Instead of worrying about which direction to put the slashes, let PowerShell do it for you.

If you need it, you can use [System.IO.Path]::DirectorySeparatorChar to get the directory separator character. For the %PATH% variable, you can use [System.IO.Path]::PathSeparator. Let’s say you want to split the %PATH% environment variable into an array of locations. A simple cross-platform approach would be $env:PATH -split [System.IO.Path]::PathSeparator. Don’t forget that Linux is case-sensitive.

Watch Your Aliases

You’ve most likely heard us go on and on about the downside of using aliases in your PowerShell scripts. Use them all you want interactively at a prompt but in written code, use full cmdlet names. Now you need to.

In the old days, we’d happily write a command like this:

Get-Process | sort handles -descending

And it would work just fine on Windows, even under PowerShell 7. But not Linux.

PS /home/jeff> Get-Process | sort handles -descending
/usr/bin/sort: invalid option -- 'e'
Try '/usr/bin/sort --help' for more information.

What happened? In PowerShell 7 on Linux, any alias that could resolve to a native command has been removed. So there is no sort alias for Sort-Object. PowerShell thinks you want to run the native sort command. If you’ve been in the habit of using Linux aliases like ps or ls, you’ll need to get over it. You need to start writing expressions like:

Get-Process | Sort-Object handles -descending

Now there’s no mistaking what you want to do and it will run everywhere.

Using Culture

If you are writing code that relies on culture information, such as from Get-Culture, note that in PowerShell 7, you won’t get the same information. In Windows PowerShell, we are used to getting output like this:

PS C:\> Get-Culture

LCID             Name             DisplayName
----             ----             -----------
1033             en-US            English (United States)

On Windows platforms running PowerShell 7, this will still work as expected. But not on non-Windows platforms. You’ll get output like this:

PS /home/jeff> Get-Culture

LCID             Name             DisplayName
----             ----             -----------
127                               Invariant Language (Invariant Country)

PS /home/jeff> Get-UICulture

LCID             Name             DisplayName
----             ----             -----------
127                               Invariant Language (Invariant Country)

You still have access to culture-specific information from the .NET Framework.

PS /home/jeff> [System.Globalization.CultureInfo]::GetCultureInfo("en-gb")

LCID             Name             DisplayName
----             ----             -----------
2057             en-GB            English (United Kingdom)

But PowerShell can’t detect the culture information on non-Windows platforms.

You’ll need to be aware of this and test your code accordingly if you are relying on detected culture information.

Leverage Remoting

One of the most anticipated features of PowerShell 7 is the use of ssh for remoting. Non-Windows systems won’t support the WSMan protocol which means no remoting the way you used to do it. But you may still want to use remoting in your toolmaking. In our opinion, leveraging remoting is a smart idea. Going to PowerShell 7 just means a little more work on your part.

One relatively easy approach you can use is parameter sets. Define one parameter set for a computer name and another for PSSession objects. PowerShell already follows this model. You can too. Here’s a proof of concept function.

Get-RemoteData
Function Get-RemoteData {
  [cmdletbinding(DefaultParameterSetName = 'computer')]
  Param(
    [Parameter(
      Position = 0,
      Mandatory,
      ValueFromPipeline,
      ParameterSetName = 'computer'
    )]
    [Alias('cn')]
    [string[]]$Computername,
    [Parameter(ParameterSetName = 'computer')]
    [alias('RunAs')]
    [PSCredential]$Credential,
    [Parameter(ValueFromPipeline, ParameterSetName = 'session')]
    [System.Management.Automation.Runspaces.PSSession]$Session
  )
  Begin {
    $sb = { "Getting remote data from $([environment]::MachineName) [$PSEdition]" }
    $PSBoundParameters.Add('Scriptblock', $sb)
  }
  Process {
    Invoke-Command @PSBoundParameters
  }
  End {}
}

The person running the function can either pass a computername with an optional credential, or a previously created PSSession object. They may have existing connections to a mix of platforms, some using SSH connections. Now they can run one command that works for all.

PS C:\> Get-PSSession | Get-RemoteData
Getting remote data from SRV2 [Desktop]
Getting remote data from FRED [Core]
Getting remote data from SRV1 [Desktop]

In this example, FRED is a Linux server running Fedora. Start simple like this.

But when you are ready, you can get very creative. Here’s a function that defines dynamic parameters if the user is running PowerShell 7.

cross-platform-scripting/Stop-RemoteProcess.ps1
#requires -version 5.1

Function Stop-RemoteProcess {
    [cmdletbinding(DefaultParameterSetName = 'computer')]
    Param(
        [Parameter(
            ParameterSetName = 'computer',
            Mandatory,
            Position = 0,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName,
            HelpMessage = 'Enter the name of a computer to query.'
        )]
        [ValidateNotNullOrEmpty()]
        [Alias('cn')]
        [string[]]$ComputerName,
        [Parameter(
            ParameterSetName = 'computer',
            HelpMessage = 'Enter a credential object or username.'
        )]
        [Alias('RunAs')]
        [PSCredential]$Credential,
        [Parameter(ParameterSetName = 'computer')]
        [switch]$UseSSL,

        [Parameter(
            ParameterSetName = 'session',
            ValueFromPipeline
        )]
        [ValidateNotNullOrEmpty()]
        [System.Management.Automation.Runspaces.PSSession[]]$Session,

        [ValidateScript( { $_ -ge 0 })]
        [int32]$ThrottleLimit = 32,

        [Parameter(Mandatory, HelpMessage = 'Specify the process to stop.')]
        [ValidateNotNullOrEmpty()]
        [string]$ProcessName,

        [Parameter(HelpMessage = 'Write the stopped process to the pipeline')]
        [switch]$Passthru,

        [Parameter(HelpMessage = 'Run the remote command with -WhatIf')]
        [switch]$WhatIfRemote
    )
    DynamicParam {
        #Add an SSH dynamic parameter if in PowerShell 7
        if ($IsCoreCLR) {
            $paramDictionary = New-Object -Type System.Management.Automation.Runtime\
DefinedParameterDictionary

            #a CSV file with dynamic parameters to create
            #this approach doesn't take any type of parameter validation into account
            $data = @'
Name,Type,Mandatory,Default,Help
HostName,string[],1,,"Enter the remote host name."
UserName,string,0,,"Enter the remote user name."
Subsystem,string,0,"powershell","The name of the ssh subsystem. The default is power\
shell."
Port,int32,0,,"Enter an alternate SSH port"
KeyFilePath,string,0,,"Specify a key file path used by SSH to authenticate the user"
SSHTransport,switch,0,,"Use SSH to connect."
'@

            $data | ConvertFrom-Csv | ForEach-Object -Begin { } -Process {
                $attributes = New-Object System.Management.Automation.ParameterAttri\
bute
                $attributes.Mandatory = ([int]$_.mandatory) -as [bool]
                $attributes.HelpMessage = $_.Help
                $attributes.ParameterSetName = 'SSH'
                $attributeCollection = New-Object -Type System.Collections.ObjectMod\
el.Collection[System.Attribute]
                $attributeCollection.Add($attributes)
                $dynParam = New-Object -Type System.Management.Automation.RuntimeDef\
inedParameter($_.name, $($_.type -as [type]), $attributeCollection)
                $dynParam.Value = $_.Default
                $paramDictionary.Add($_.name, $dynParam)
            } -End {
                return $paramDictionary
            }
        }
    } #dynamic param

    Begin {
        $start = Get-Date
        #the first verbose message uses a pseudo timespan to reflect the idea we're \
just starting
        Write-Verbose "[00:00:00.0000000 BEGIN  ] Starting $($MyInvocation.MyCommand\
)"

        #a script block to be run remotely
        Write-Verbose "[$(New-TimeSpan -Start $start) BEGIN  ] Defining the scriptbl\
ock to be run remotely"

        $sb = {
            param([string]$ProcessName, [bool]$Passthru, [string]$VerbPref = 'Silent\
lyContinue', [bool]$WhatPref)

            $VerbosePreference = $VerbPref
            $WhatIfPreference = $WhatPref

            Try {
                Write-Verbose "[$(New-TimeSpan -Start $using:start) REMOTE ] Getting\
 Process $ProcessName on $([System.Environment]::MachineName)"
                $Processes = Get-Process -Name $ProcessName -ErrorAction stop
                Try {
                    Write-Verbose "[$(New-TimeSpan -Start $using:start) REMOTE ] Sto\
pping $($Processes.count) Processes on $([System.Environment]::MachineName)"
                    $Processes | Stop-Process -ErrorAction Stop -PassThru:$Passthru
                }
                Catch {
                    Write-Warning "[$(New-TimeSpan -Start $using:start) REMOTE ] Fai\
led to stop Process $ProcessName on $([System.Environment]::MachineName). $($_.Excep\
tion.message)."
                }
            }
            Catch {
                Write-Verbose "[$(New-TimeSpan -Start $using:start) REMOTE ] Process\
 $ProcessName not found on $([System.Environment]::MachineName)"
            }

        } #scriptblock

        #parameters to splat to Invoke-Command
        Write-Verbose "[$(New-TimeSpan -Start $start) BEGIN  ] Defining parameters f\
or Invoke-Command"

        #remove my parameters from PSBoundParameters because they can't be used with\
 New-PSSession
        $myParams = 'ProcessName', 'WhatIfRemote', 'passthru'
        foreach ($my in $myParams) {
            if ($PSBoundParameters.ContainsKey($my)) {
                [void]($PSBoundParameters.remove($my))
            }
        }

        $icmParams = @{
            Scriptblock      = $sb
            ArgumentList     = @($ProcessName, $Passthru, $VerbosePreference, $WhatI\
fRemote)
            HideComputerName = $False
            ThrottleLimit    = $ThrottleLimit
            ErrorAction      = 'Stop'
            Session          = $null
        }

        #initialize an array to hold session objects
        [System.Management.Automation.Runspaces.PSSession[]]$All = @()
        If ($Credential.username) {
            Write-Verbose "[$(New-TimeSpan -Start $start) BEGIN  ] Using alternate c\
redential for $($credential.username)"
        }
    } #begin

    Process {
        Write-Verbose "[$(New-TimeSpan -Start $start) PROCESS] Detected parameter se\
t $($PSCmdlet.ParameterSetName)."

        $remotes = @()
        if ($PSCmdlet.ParameterSetName -match 'computer|ssh') {
            if ($PSCmdlet.ParameterSetName -eq 'ssh') {
                $remotes += $PSBoundParameters.HostName
                $param = 'HostName'
            }
            else {
                $remotes += $PSBoundParameters.ComputerName
                $param = 'ComputerName'
            }

            foreach ($remote in $remotes) {
                $PSBoundParameters[$param] = $remote
                $PSBoundParameters['ErrorAction'] = 'Stop'
                Try {
                    #create a session one at a time to better handle errors
                    Write-Verbose "[$(New-TimeSpan -Start $start) PROCESS] Creating \
a temporary PSSession to $remote"
                    #save each created session to $tmp so it can be removed at the e\
nd
                    $all += New-PSSession @PSBoundParameters -OutVariable +tmp
                } #Try
                Catch {
                    #TODO: Decide what you want to do when the new session fails
                    Write-Warning "Failed to create session to $remote. $($_.Excepti\
on.Message)."
                    #Write-Error $_
                } #catch
            } #foreach remote
        }
        Else {
            #only add open sessions
            foreach ($sess in $session) {
                if ($sess.state -eq 'opened') {
                    Write-Verbose "[$(New-TimeSpan -Start $start) PROCESS] Using ses\
sion for $($sess.ComputerName.ToUpper())"
                    $all += $sess
                } #if open
            } #foreach session
        } #else sessions
    } #process

    End {
        $icmParams['session'] = $all
        Try {
            Write-Verbose "[$(New-TimeSpan -Start $start) END    ] Querying $($all.c\
ount) computers"

            Invoke-Command @icmParams | ForEach-Object {
                #TODO: PROCESS RESULTS FROM EACH REMOTE CONNECTION IF NECESSARY
                $_
            } #foreach result
        } #try
        Catch {
            Write-Error $_
        } #catch

        if ($tmp) {
            Write-Verbose "[$(New-TimeSpan -Start $start) END    ] Removing $($tmp.c\
ount) temporary PSSessions"
            $tmp | Remove-PSSession
        }
        Write-Verbose "[$(New-TimeSpan -Start $start) END    ] Ending $($MyInvocatio\
n.MyCommand)"
    } #end
} #close function

This function essentially follows the same model as the previous example. But this one creates several dynamic parameters if PowerShell 7 is detected. Notice we’re using one of the new variables. When the user runs help in PowerShell 7, they’ll get the new parameters.

Stop-RemoteProcess Help
Stop-RemoteProcess Help

The last parameter set is the dynamic one. Instead of having to write several versions of the command, you can write a single function. Check out https://jdhitsolutions.com/blog/powershell/7458/a-powershell-remote-function-framework/ for a bit more detail on this function.

Custom Module Manifests

The last cross-platform scripting feature to consider is a custom module manifest. In the code downloads for this chapter, you’ll find a demo module called CrossDemo. The module has several commands, some of which will only work in PowerShell 7. The goal is to only export the commands (and aliases) that are supported. Here’s how.

Normally, a psd1 is static and can’t contain code. But there is an exception for a module manifest. You can use a simple If statement to tell PowerShell what functions to export.

FunctionsToExport = if ($PSEdition -eq 'desktop') {
    'Export-Data','Get-DiskFree'
    }
    else {
    'Export-Data','Get-DiskFree','Get-Status','Get-RemoteData'
    }
...
AliasesToExport = if ($PSEdition -eq 'desktop') {
    'df'
    }
    else {
    'df','gst'
    }

When the module is imported on Windows PowerShell, only 2 functions and 1 alias are exported. PowerShell 7 systems get everything.

Even so, you still may need to fine-tune platform requirements. For example, if you look at our sample code for the Get-DiskFree function, you’ll see code like this:

 if ($IsWindows -OR $PSEdition -eq 'desktop') {
        $Drive = (Get-Item $Path).Root -replace "\\"
        Write-Verbose "Getting disk information for $drive in $As"
        ...
    } #if Windows
    else {
        Write-Warning 'This command requires a Windows platform.'
    }

Because the function uses Get-CimInstance it requires a Windows platform. It will work in PowerShell 7 on Windows but not Linux. So even though we’re exporting the function for PowerShell 7, it will only run on Windows. You’ll need to keep things like this in mind. Will it work in PowerShell 7 and what are the platform dependencies?

We’ll be honest with you and say that a lot of community-accepted best practices for cross-platform scripting are still being developed. But if you use a little common sense and follow the best practices that are accepted, you shouldn’t have too much trouble.