Sharing PowerShell code internally with module development

It’s time for a blog about PowerShell. How exciting! In this first part, you are going to learn to create an awesome developer experience for newcomers to the project, how to scaffold your module and to perform static code analysis for the scripts.

From Monad to PowerShell

PowerShell has been there since the dawn of time. Not that dawn of time; in fact, PowerShell wasn’t even called PowerShell when it was first launched. It was originally called Monad when it was launched to the public. Microsoft later renamed it, and many versions of PowerShell have been launched since then, even becoming open-source from PowerShell 6.0 core.

When you first started out learning PowerShell, you probably typed in a bunch of cmdlets directly from the shell itself to perform an action. Later you would have learned that it also has an Integrated Scripting Environment, the so called PowerShell ISE. You would start by adding some error handling around the cmdlets and save it as .ps1 format, so you could run it later. Maybe you already shared those scripts with your end users by saving your scripts to a .psm1 format. In that way, they could directly load it into their shell by using the Import-Module cmdlet. Well, that’s where this blog series is going to be all about.

Let’s get started

But first, a small warning. You have read that there was quite a jump from cmdlets to a .psm1 module file. If this sounds unfamiliar to you, it might be hard to grasp the concepts that you’ll see in this blog. Nevertheless, for the eager learner it will definitely provide great insights and inspiration for the future PowerShell code that you will produce. If that’s the case, then let’s get started!

Optimizing developer experience for PowerShell module development with Visual Studio Code

Most IT pros use the PowerShell ISE to build their PowerShell scripts. By now, Microsoft dropped almost development for PowerShell ISE and recommends users switch to Visual Studio Code (also known as VS Code), since it’s a much more powerful, customizable and flexible free editor. This blog will use VS Code as its editor with useful extensions to customize your development experience when you write your PowerShell scripts. If you already have VS Code installed, great! If not, grab your free VS Code from the Microsoft site.

VS Code has a rich extensibility model that lets you install extensions to support your development workflow. One of those extensions, and you might already have guessed it, is the PowerShell extension. Some of the major features of this extension are:

  • Syntax highlighting
  • Code snippets
  • IntelliSense

To install the extension, you can type the following in any command-line shell:

code --install-extension ms-vscode.powershell

If you’ve installed the extension and created a .ps1, you may have noticed that these features became available to you.

Did you say indentation?

Now that you have the PowerShell extension installed, let’s look at indentation. VS Code helps you quite a bit with indentation, and if you like, you can specify the spaces or tabs to use in your editor settings. Still, it might sometimes be difficult to see when you’re writing your script. One of the extensions that can help you out, is the indent-rainbow extension. Let’s see how it looks like by installing the extension:

code --install-extension oderwat.indent-rainbow

Now press Ctrl+Shift+P and type in Preferences: Open Workspace Settings (JSON) and paste in the following JSON code:

"indentRainbow.colors": [
    "rgba(255,255,64,0.3)",
    "rgba(127,255,127,0.3)",
    "rgba(255,127,255,0.3)",
    "rgba(79,236,236,0.3)"
]

Let’s see both differences with and without.

Screenshot of PowerShell code without the rainbow extension
Figure 1: Without the indentation rainbow extension.
Screenshot showing PowerShell code with the rainbow extension enabled
Figure 2: With indentation rainbow extension enabled.

It might be a bit extreme with the coloring, but hey! It does the trick, not only for you, but maybe also for less-attentive newcomers, right?!

Charging Git version control with GitLens

When you are storing your precious scripts in Git version control, you can charge up your experience and see who changed what in a glance with the GitLens extension. Again, open the command-line shell, install the extension and see what happens in a working Git repository.

code --install-extension eamodio.gitlens

Let’s see the differences for this extension when it is enabled or disabled.

Screenshot showing PowerShell code with GitLens disabled
Figure 3: GitLens disabled.
Screenshot showing PowerShell code with GitLens enabled
Figure 4: GitLens enabled.

On the extension page, you can find many more features that GitLens provides.

Visualizing icons and To-Dos

Lastly, VSCode-icons and Todo Tree are both visualizing extensions to give a boost to the visualizing experience when working in VS Code. Run the following commands in the shell to install both extensions:

code --install-extension vscode-icons-team.vscode-icons
code --install-extension gruntfuggly.todo-tree

You can probably immediately see that the icons are changed when you have some file or folders available in your editor. The Todo Tree gives you an easy experience when you are writing code that might not be finished, and you added the famous TODO comment to directly go to the specific line. It even highlights when you are inside the file itself.

Screenshot showing how to add the todo comment in your code
Figure 5: Some work to do with Todo tree.

Now of course, you have installed those extensions on your own laptop or computer, but what about your newcomers? When you are working in a repository, it is possible to configure recommended extensions by opening the Extensions: Configure Recommended Extensions (Workspace Folder) from the command palette (Ctrl+Shift+P). Adding the following code in the extensions.json file, will help your newcomers when checking out the repository, to get the recommendations and install the extensions belonging to the workspace:

{
  "recommendations": ["ms-vscode.powershell", "oderwat.indent-rainbow", "eamodio.gitlens", "vscode-icons-team.vscode-icons", "gruntfuggly.todo-tree"]
}

Scaffolding project files with Sampler

Scaffolding is building a common directory and file structure, just like creating a Visual Studio project where you have that /bin and /obj folder created for you by the editor. While scaffolding is fairly new for PowerShell module development, there have been some popular scaffolding modules available for PowerShell. One of these scaffolding modules is called Sampler. Let’s see that in action.

In your PowerShell terminal, you can install Sampler by using the Install-Module cmdlet:

Install-Module -Name 'Sampler' -Scope 'CurrentUser' -Repository PSGallery

Sampler has a bunch of cmdlets available in the module, but the most interesting one is the New-SampleModule cmdlet. Let’s create a module with minimal structure and automation by running the following code:

$newSampleModuleParameters = @{
    DestinationPath   = 'C:\source'
    ModuleType        = 'SimpleModule' # There are different module types you can give
    ModuleName        = 'PoweringUp'
    ModuleAuthor      = 'Gijs Reijn'
    ModuleDescription = 'Powering up Powershell module development'
}

New-SampleModule @newSampleModuleParameters

If you’ve ran the cmdlet and opened the project in VS Code, you can see that a bunch of files have been created.

Screenshot showing sampler module file and folder structure
Figure 6: Sampler module file and folder structure.

Let’s quickly cover the files and folders that have been created by Sampler:

  • build.ps1: entry point to invoke tasks leveraging the Invoke-Build task runner
  • build.yaml: defines several workflows that can be called when executing .\build.ps1 -Tasks <Workflow_or_task_Name>
  • azure-pipelines.yml: predefined build and release pipeline for Azure DevOps
  • CHANGELOG.md: changelog that contains changes for each version of the project
  • GitVersion.yml: tool that generates Semantic Version numbering
  • source: contains Public and Private folders to store your PowerShell functions
  • tests: Pester tests that can be grouped to be executed
  • output: consists out of required modules and the final produced PoweringUp module

To list the available tasks, you can run the ./build -Tasks ? in your PowerShell terminal. Before you build the module, let’s introduce a new function in the Public folder and Private folder to see the behavior what happens when you build the module. Both these scripts come from the old Technet Gallery, but are slightly modified.

  1. In the Public folder, create a file called Get-PendingReboot.ps1
  2. Paste the following code inside the file and save it
Function Get-PendingReboot {
    [CmdletBinding()]
    param(
        [Parameter(Position = 0, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias("CN", "Computer")]
        [String[]]$ComputerName = "$env:COMPUTERNAME"
    )

    Begin {  }## End Begin Script Block
    Process {
        Foreach ($Computer in $ComputerName) {
            Try {
                ## Setting pending values to false to cut down on the number of else statements
                $CompPendRen, $PendFileRename, $Pending, $SCCM = $false, $false, $false, $false
                        
                ## Setting CBSRebootPend to null since not all versions of Windows has this value
                $CBSRebootPend = $null
                        
                ## Querying WMI for build version
                $WMI_OS = Get-CimInstance -Class Win32_OperatingSystem -Property BuildNumber, CSName -ComputerName $Computer -ErrorAction Stop

                ## Making registry connection to the local/remote computer
                $HKLM = [UInt32] "0x80000002"
                $WMI_Reg = [WMIClass] "\\$Computer\root\default:StdRegProv"
                        
                ## If Vista/2008 & Above query the CBS Reg Key
                If ([Int32]$WMI_OS.BuildNumber -ge 6001) {
                    $RegSubKeysCBS = $WMI_Reg.EnumKey($HKLM, "SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\")
                    $CBSRebootPend = $RegSubKeysCBS.sNames -contains "RebootPending"        
                }
                            
                ## Query WUAU from the registry
                $RegWUAURebootReq = $WMI_Reg.EnumKey($HKLM, "SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\")
                $WUAURebootReq = $RegWUAURebootReq.sNames -contains "RebootRequired"
                        
                ## Query PendingFileRenameOperations from the registry
                $RegSubKeySM = $WMI_Reg.GetMultiStringValue($HKLM, "SYSTEM\CurrentControlSet\Control\Session Manager\", "PendingFileRenameOperations")
                $RegValuePFRO = $RegSubKeySM.sValue

                ## Query JoinDomain key from the registry - These keys are present if pending a reboot from a domain join operation
                $Netlogon = $WMI_Reg.EnumKey($HKLM, "SYSTEM\CurrentControlSet\Services\Netlogon").sNames
                $PendDomJoin = ($Netlogon -contains 'JoinDomain') -or ($Netlogon -contains 'AvoidSpnSet')

                ## Query ComputerName and ActiveComputerName from the registry
                $ActCompNm = $WMI_Reg.GetStringValue($HKLM, "SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName\", "ComputerName")            
                $CompNm = $WMI_Reg.GetStringValue($HKLM, "SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName\", "ComputerName")

                If (($ActCompNm -ne $CompNm) -or $PendDomJoin) {
                    $CompPendRen = $true
                }
                        
                ## If PendingFileRenameOperations has a value set $RegValuePFRO variable to $true
                If ($RegValuePFRO) {
                    $PendFileRename = $true
                }

                ## Determine SCCM 2012 Client Reboot Pending Status
                ## To avoid nested 'if' statements and unneeded WMI calls to determine if the CCM_ClientUtilities class exist, setting EA = 0
                $CCMClientSDK = $null
                $CCMSplat = @{
                    NameSpace    = 'ROOT\ccm\ClientSDK'
                    Class        = 'CCM_ClientUtilities'
                    Name         = 'DetermineIfRebootPending'
                    ComputerName = $Computer
                    ErrorAction  = 'Stop'
                }
                ## Try CCMClientSDK
                Try {
                    $CCMClientSDK = Invoke-CimMethod @CCMSplat
                }
                Catch [System.UnauthorizedAccessException] {
                    $CcmStatus = Get-Service -Name CcmExec -ComputerName $Computer -ErrorAction SilentlyContinue
                    If ($CcmStatus.Status -ne 'Running') {
                        LogWrite -Message "$Computer`: Error - CcmExec service is not running."
                        $CCMClientSDK = $null
                    }
                }
                Catch {
                    $CCMClientSDK = $null
                }

                If ($CCMClientSDK) {
                    If ($CCMClientSDK.ReturnValue -ne 0) {
                        LogWrite -Message "Error: DetermineIfRebootPending returned error code $($CCMClientSDK.ReturnValue)"          
                    }
                    If ($CCMClientSDK.IsHardRebootPending -or $CCMClientSDK.RebootPending) {
                        $SCCM = $true
                    }
                }
            
                Else {
                    $SCCM = $null
                }

                ## Creating Custom PSObject and Select-Object Splat
                $SelectSplat = @{
                    Property = (
                        'Computer',
                        'CBServicing',
                        'WindowsUpdate',
                        'CCMClientSDK',
                        'PendComputerRename',
                        'PendFileRename',
                        'PendFileRenVal',
                        'RebootPending'
                    )
                }
                New-Object -TypeName PSObject -Property @{
                    Computer           = $WMI_OS.CSName
                    CBServicing        = $CBSRebootPend
                    WindowsUpdate      = $WUAURebootReq
                    CCMClientSDK       = $SCCM
                    PendComputerRename = $CompPendRen
                    PendFileRename     = $PendFileRename
                    PendFileRenVal     = $RegValuePFRO
                    RebootPending      = ($CompPendRen -or $CBSRebootPend -or $WUAURebootReq -or $SCCM -or $PendFileRename)
                } | Select-Object @SelectSplat

            }
            Catch {
                LogWrite -Message "$Computer`: $_"
            }
        }## End Foreach ($Computer in $ComputerName)
    }## End Process

    End {  }## End End

}## End Function Get-PendingReboot
  1. In the private folder, create a file called LogWrite.ps1
  2. Inside the file, paste the following code
Function LogWrite {
    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory = $true,
            ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias("LogContent")]
        [string]$Message,

        [Parameter(Mandatory = $false)]
        [Alias('LogPath')]
        [string]$Path = 'C:\Logs\PowerShellLog.log',
        
        [Parameter(Mandatory = $false)]
        [ValidateSet("Error", "Warn", "Info")]
        [string]$Level = "Info",
        
        [Parameter(Mandatory = $false)]
        [switch]$NoClobber
    )

    Begin {
        # Set VerbosePreference to Continue so that verbose messages are displayed.
        $VerbosePreference = 'Continue'
    }
    Process {
        
        # If the file already exists and NoClobber was specified, do not write to the log.
        if ((Test-Path $Path) -AND $NoClobber) {
            Write-Error "Log file $Path already exists, and you specified NoClobber. Either delete the file or specify a different name."
            Return
        }

        # If attempting to write to a log file in a folder/path that doesn't exist create the file including the path.
        elseif (!(Test-Path $Path)) {
            Write-Verbose "Creating $Path."
            $NewLogFile = New-Item $Path -Force -ItemType File
        }

        else {
            # Nothing to see here yet.
        }

        # Format Date for our Log File
        $FormattedDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss"

        # Write message to error, warning, or verbose pipeline and specify $LevelText
        switch ($Level) {
            'Error' {
                Write-Error $Message
                $LevelText = 'ERROR:'
            }
            'Warn' {
                Write-Warning $Message
                $LevelText = 'WARNING:'
            }
            'Info' {
                Write-Verbose $Message
                $LevelText = 'INFO:'
            }
        }
        
        # Write log entry to $Path
        "$FormattedDate $LevelText $Message" | Out-File -FilePath $Path -Append
    }
    End {
    }
}

Let’s start the build workflow by running ./build.ps1 -Tasks build from your PowerShell terminal. Below you’ll see the following output in the output directory.

Screenshot showing the build output from build script.
Figure 7: Build output from build script.

Open up a new PowerShell terminal and import the module manifest file with the Import-Module cmdlet.

Screenshot showing importing the module in PowerShell session
Figure 8: Importing the module in PowerShell session.

Fantastic! Now you see that only the Get-PendingReboot function is available to you, but is missing the LogWrite function. Then why is the LogWrite function not available? Glad you asked. When the module is built with the ModuleBuilder module, which separates both Public and Private functions by adding only the Public functions in the manifest file in the FunctionsToExport entry. If you open the PoweringUp.psd1 in the output directory, you’ll spot that specific entry.

Image showing functions that are available to the end user
Figure 9: Functions that are available to the end user.

You can debate about the design and whether to call it Public and Private or Functions and Internal, since both options are common in the PowerShell community. Both options do the same trick by hiding functions that are internal to the module and not available to the end users.

A word about rules with PSScriptAnalyzer

As mentioned before, both the scripts come from the Technet Gallery and were created by awesome folks. The original LogWrite function was called Write-Log, but why was the name changed? You probably haven’t noticed any warnings popping up in your VS Code editor, and you can argue that LogWrite isn’t an approved verb to be used. That’s exactly what is demonstrated here, because if you change it to Log-Write, you’ll see a warning from the editor:

Screenshot showing the log-write function using an unapproved verb
Figure 10: Log-Write function using unapproved verb.

The analyzer, called PSScriptAnalyzer, runs these rules, which are baked into to the PowerShell extension that you installed in the beginning if you’re following along. PSScriptAnalyzer is a static code checker for PowerShell modules and scripts. It can check the quality of the code by running against a set of rules. If you want to control the behavior of the analyzer, you can introduce a settings file where you can specify the rules you want to check against.

  1. In the root of the repository, create a file called PSScriptAnalyzerSettings.psd1
  2. For demonstrating purposes, add the following content without the PSUseApprovedVerbs
@{
    IncludeRules = @('PSAvoidDefaultValueSwitchParameter',
        'PSMisleadingBacktick',
        'PSMissingModuleManifestField',
        'PSReservedCmdletChar',
        'PSReservedParams',
        'PSShouldProcess',
        'PSAvoidUsingCmdletAliases',
        'PSUseDeclaredVarsMoreThanAssignments')
}
  1. Restart the editor

You’ll notice after the editor is restarted, there are no more squiggly lines telling you that it is using an unapproved verb. If you want to get a full list of available rules, you can simple run the Get-ScriptAnalyzerRule cmdlet or visit the documentation. For now, make sure you add the rule and change the function name to the original.

Conclusion

You’ve already covered quite some ground. You saw by installing extensions in VS Code, you can create an awesome developer experience. Not only for yourself, but also for newcomers when they check out your repository. After that, you went through the scaffolding of the project files to start introducing new PowerShell scripts that get build into a so called module.

In the next part of this series, you will cover even more by checking out the different formatting styles, building your own verb-noun pair and ensuring greater quality with Pester tests. If you are already up for an exercise, can you spot how the build workflow works and runs? See you in the next part!

References

About the author

Gijs Reijn
Cloud Engineer

Gijs Reijn is the DevOps Engineer at Rabobank’s ALM IT department. He primarily focusses on Azure DevOps, Azure and loves to automate processes including standardization around it. Outside working hours, he can be found in the early morning working out in the gym nearly every day, writes his own blog to share knowledge with the community and reading upon new ideas.

Related articles

5 best practices for using Azure Bicep

  • 17 August 2022
  • 12 min
By Gijs Reijn

Juicing it up: testing best practices for Azure Bicep

  • 21 September 2022
  • 7 min
By Gijs Reijn

Stitching it together: pipeline best practices for Azure Bicep

  • 24 October 2022
  • 8 min
By Gijs Reijn