The PowerShell Iron Scripter: My solution to prequel puzzle 1

Each week leading up to the PowerShell + DevOps Global Summit 2018, PowerShell.org will be posting an iron scripter prequel puzzle on their website. As their website states, think of the iron scripter as the successor to the scripting games.

iron-scripter175x175.jpg

I've taken a look at the different factions and it was a difficult choice for me to choose between the Daybreak and Flawless faction. While I try to write code that's flawless, perfection is in the eye of the beholder and it's also a never-ending moving target. Today's perfect code is tomorrow's hot mess because one's knowledge and experience are both constantly increasing, or at least they should be if you want to remain relevant in this industry. I used some of the comments I saw in a tweet from Joel Bennett to help me choose between the two and I ended up choosing the Daybreak faction.

In the first puzzle, you're given some code that simply does not work due to numerous errors. The instructions even state there are errors in the code. I started out by cleaning up the supplied code a bit to at least make it work so I'd have a better understanding of what it's trying to accomplish.

 1$Monitor = Get-WmiObject -Class wmiMonitorID -Namespace root\wmi
 2$Computer = Get-WmiObject -Class Win32_ComputerSystem
 3
 4$Monitor | ForEach-Object {
 5     $psObject = New-Object -TypeName PSObject
 6     $psObject | Add-Member -MemberType NoteProperty -Name ComputerName -Value ''
 7     $psObject | Add-Member -MemberType NoteProperty -Name ComputerType -Value ''
 8     $psObject | Add-Member -MemberType NoteProperty -Name ComputerSerial -Value ''
 9     $psObject | Add-Member -MemberType NoteProperty -Name MonitorSerial -Value ''
10     $psObject | Add-Member -MemberType NoteProperty -Name MonitorType -Value ''
11     $psObject.ComputerName = $env:COMPUTERNAME
12     $psObject.ComputerType = $Computer.Model
13     $psObject.ComputerSerial = $Computer.Name
14     $psObject.MonitorSerial = ($_.SerialNumberID | ForEach-Object {[char]$_}) -join ''
15     $psObject.MonitorType = ($_.UserFriendlyName | ForEach-Object {[char]$_}) -join ''
16     $psObject
17}

prequel1-ironscripter3a.jpg

If I were part of the Battle faction, I would probably quit here and say it works so that's good enough.

Throwing a prototype function together to query the local system was simple enough. Using the CIM cmdlets instead of the older WMI ones allows it to run on PowerShell Core 6.0 in addition to Windows PowerShell 4.0+. Although the CIM cmdlets were introduced in PowerShell version 3.0, I choose to use the ForEach method which wasn't introduced until PowerShell version 4.0.

In case you didn't already know, PowerShell Core version 6.0 is not an upgrade or replacement to Windows PowerShell version 5.1. It installs side by side on Windows systems. Based on the response to a tweet of mine from Don Jones, it appears that I'm not the only one who thought PowerShell Core should have been version 1.0 instead of 6.0 to help differentiate it and eliminate some of the confusion.

Specifying the Property parameter with Get-CimInstance to limit the properties returned makes my Get-MonitorInfo function shown in the following example more efficient. After all, there's no reason whatsoever to retrieve data that's never going to be used. I decided to keep things as simple as possible and write a regular function instead of an advanced one. It could be turned into an advanced function by simply adding cmdlet binding which also requires a param block even if it's empty.

 1#Requires -Version 4.0
 2function Get-MonitorInfo {
 3
 4<#
 5.SYNOPSIS
 6    Retrieves information about the monitors connected to the local system.
 7
 8.DESCRIPTION
 9    Get-MonitorInfo is a function that retrieves information about the monitors connected to the local system.
10
11.EXAMPLE
12     Get-MonitorInfo
13
14.INPUTS
15    None
16
17.OUTPUTS
18    PSCustomObject
19
20.NOTES
21    Author:  Mike F Robbins
22    Website: http://mikefrobbins.com
23    Twitter: @mikefrobbins
24#>
25
26    $Computer = Get-CimInstance -ClassName Win32_ComputerSystem -Property Name, Model
27    $BIOS = Get-CimInstance -ClassName Win32_BIOS -Property SerialNumber
28    $Monitors = Get-CimInstance -ClassName WmiMonitorID -Namespace root/WMI -Property SerialNumberID, UserFriendlyName
29
30    foreach ($Monitor in $Monitors) {
31        [pscustomobject]@{
32            ComputerName = $Computer.Name
33            ComputerType = $Computer.Model
34            ComputerSerial = $BIOS.SerialNumber
35            MonitorSerial = -join $Monitor.SerialNumberID.ForEach({[char]$_})
36            MonitorType = -join $Monitor.UserFriendlyName.ForEach({[char]$_})
37        }
38    }
39
40}

This function works as expected against a local system with multiple monitors, but it's run against a VM with a single monitor in this example.

1Get-MonitorInfo

prequel1-ironscripter4a.jpg

While the function seems to meet the requirements of the solution, I wanted to take it a step further and write something that I would use in a production environment. After all, if you're going to go to all of this effort, why not write something useful.

Functions belong in modules so I'll start out by using my New-MrScriptModule function from my MrToolkit module to create a new module named MrIronScripter.

1New-MrScriptModule -Name MrIronScripter -Path "$env:ProgramFiles\WindowsPowerShell\Modules" -Author 'Mike F Robbins' -CompanyName mikefrobbins.com -Description 'PowerShell Module containing scripts and functions from the Iron Scripter Competition' -PowerShellVersion 4.0

prequel1-ironscripter1a.jpg

Once the module is created, I use New-MrFunction to create a function named Get-MrMonitorInfo.

1New-MrFunction -Name Get-MrMonitorInfo -Path "$env:ProgramFiles\WindowsPowerShell\Modules\MrIronScripter"

prequel1-ironscripter2a.jpg

I wanted the function to be able to run against remote computers. Since it uses the CIM cmdlets, I decided to rely on CIM sessions for remote connectivity. Replying on them eliminates the need to code remote connectivity and the ability to specify alternate credentials into my function. All of that is taken care of when establishing a CIM session with the New-CimSession cmdlet. My New-MrCimSession function could also be used to establish a CIM session using WSMAN with automated negotiation down to DCOM depending on which one is available.

Most functions iterate though each remote computer one at a time, but why limit your function to querying one computer at a time? One of my goals was to query all of the remote computers at once which would be much more efficient from a performance standpoint.

 1#Requires -Version 4.0
 2function Get-MrMonitorInfo {
 3
 4<#
 5.SYNOPSIS
 6    Retrieves information about the monitors connected to the specified system.
 7
 8.DESCRIPTION
 9    Get-MrMonitorInfo is an advanced function that retrieves information about the monitors
10    connected to the specified system.
11
12.PARAMETER CimSession
13    Specifies the CIM session to use for this function. Enter a variable that contains the CIM session or a command that
14    creates or gets the CIM session, such as the New-CimSession or Get-CimSession cmdlets. For more information, see
15    about_CimSessions.
16
17.EXAMPLE
18     Get-MrMonitorInfo
19
20.EXAMPLE
21     Get-MrMonitorInfo -CimSession (New-CimSession -ComputerName Server01, Server02)
22
23.INPUTS
24    None
25
26.OUTPUTS
27    Mr.MonitorInfo
28
29.NOTES
30    Author:  Mike F Robbins
31    Website: http://mikefrobbins.com
32    Twitter: @mikefrobbins
33#>
34
35    [CmdletBinding()]
36    [OutputType('Mr.MonitorInfo')]
37    param (
38        [Microsoft.Management.Infrastructure.CimSession[]]$CimSession
39    )
40
41    $Params = @{
42        ErrorAction = 'SilentlyContinue'
43        ErrorVariable = 'Problem'
44    }
45
46    if ($PSBoundParameters.CimSession) {
47        $Params.CimSession = $CimSession
48    }
49
50    $ComputerInfo = Get-CimInstance @Params -ClassName Win32_ComputerSystem -Property Name, Manufacturer, Model
51    $BIOS = Get-CimInstance @Params -ClassName Win32_BIOS -Property SerialNumber
52    $Monitors = Get-CimInstance @Params -ClassName WmiMonitorID -Namespace root/WMI -Property ManufacturerName, UserFriendlyName, ProductCodeID, SerialNumberID, WeekOfManufacture, YearOfManufacture
53
54    foreach ($Computer in $ComputerInfo) {
55
56        foreach ($Monitor in $Monitors | Where-Object {-not $_.PSComputerName -or $_.PSComputerName -eq $Computer.Name}) {
57
58            if (-not $PSBoundParameters.CimSession) {
59
60                Write-Verbose -Message "Running against the local system. Setting value for PSComputerName (a read-only property) to $env:COMPUTERNAME."
61                ($BIOS.GetType().GetField('_CimSessionComputerName','static,nonpublic,instance')).SetValue($BIOS,$Computer.Name)
62
63            }
64
65            [pscustomobject]@{
66                ComputerName = $Computer.Name
67                ComputerManufacturer = $Computer.Manufacturer
68                ComputerModel = $Computer.Model
69                ComputerSerial = ($BIOS | Where-Object PSComputerName -eq $Computer.Name).SerialNumber
70                MonitorManufacturer = -join $Monitor.ManufacturerName.ForEach({[char]$_})
71                MonitorModel = -join $Monitor.UserFriendlyName.ForEach({[char]$_})
72                ProductCode = -join $Monitor.ProductCodeID.ForEach({[char]$_})
73                MonitorSerial = -join $Monitor.SerialNumberID.ForEach({[char]$_})
74                MonitorManufactureWeek = $Monitor.WeekOfManufacture
75                MonitorManufactureYear = $Monitor.YearOfManufacture
76                PSTypeName = 'Mr.MonitorInfo'
77            }
78
79        }
80
81    }
82
83    foreach ($p in $Problem) {
84        Write-Warning -Message "An error occurred on $($p.OriginInfo). $($p.Exception.Message)"
85    }
86
87}

Can a read-only property be modified in PowerShell? Keep reading while keeping an open mind to find out.

The PSComputerName property isn't populated when running against the local system. That's one of the problems I ran into when trying to put the pieces back together from the different WMI classes to return the results from each individual system. That property is also read-only and generates an error when trying to update the value it contains in the BIOS variable. I found a solution thanks to a blog article by Boe Prox. Due to differences in the names and where the PSComputerName property is actually pulled from, my example wasn't quite as straight forward as the one in Boe's example, but his article pointed me in the right direction.

1$BIOS = Get-CimInstance -ClassName Win32_BIOS -Property SerialNumber
2$BIOS.PSComputerName
3$BIOS.PSComputerName = 'FN2187'
4($BIOS.GetType().GetField('_CimSessionComputerName','static,nonpublic,instance')).SetValue($BIOS,'FN2187')
5$BIOS.PSComputerName

prequel1-ironscripter5a.jpg

I also discovered that I couldn't use traditional error handling because it wouldn't return results for any systems if any single one of them failed. What I ended up doing was to iterate through the error variable and output the errors as warnings if any were generated.

I also decided to return more information than was specified in the Iron Scripter event. I created custom formatting so only the properties specified in their provided code example would be returned by default in either a table or a list view. One change I did make is to use the word "Model" instead of "Type" for the computer and monitor model.

 1<?xml version="1.0" encoding="utf-8" ?>
 2<Configuration>
 3    <ViewDefinitions>
 4        <View>
 5            <Name>Mr.MonitorInfo</Name>
 6            <ViewSelectedBy>
 7                <TypeName>Mr.MonitorInfo</TypeName>
 8            </ViewSelectedBy>
 9            <TableControl>
10                <TableHeaders>
11                     <TableColumnHeader>
12                        <Width>16</Width>
13                    </TableColumnHeader>
14                    <TableColumnHeader>
15                        <Width>16</Width>
16                    </TableColumnHeader>
17                    <TableColumnHeader>
18                        <Width>33</Width>
19                    </TableColumnHeader>
20                    <TableColumnHeader>
21                        <Width>16</Width>
22                    </TableColumnHeader>
23                    <TableColumnHeader>
24                        <Width>33</Width>
25                    </TableColumnHeader>
26                </TableHeaders>
27                <TableRowEntries>
28                    <TableRowEntry>
29                        <TableColumnItems>
30                            <TableColumnItem>
31                                <PropertyName>ComputerName</PropertyName>
32                            </TableColumnItem>
33                            <TableColumnItem>
34                                <PropertyName>ComputerModel</PropertyName>
35                            </TableColumnItem>
36                            <TableColumnItem>
37                                <PropertyName>ComputerSerial</PropertyName>
38                            </TableColumnItem>
39                            <TableColumnItem>
40                                <PropertyName>MonitorModel</PropertyName>
41                            </TableColumnItem>
42                            <TableColumnItem>
43                                <PropertyName>MonitorSerial</PropertyName>
44                            </TableColumnItem>
45                        </TableColumnItems>
46                    </TableRowEntry>
47                 </TableRowEntries>
48            </TableControl>
49        </View>
50        <View>
51            <Name>Mr.MonitorInfo</Name>
52            <ViewSelectedBy>
53                <TypeName>Mr.MonitorInfo</TypeName>
54            </ViewSelectedBy>
55            <ListControl>
56                <ListEntries>
57                    <ListEntry>
58                        <ListItems>
59                            <ListItem>
60                                <PropertyName>ComputerName</PropertyName>
61                            </ListItem>
62                            <ListItem>
63                                <PropertyName>ComputerModel</PropertyName>
64                            </ListItem>
65                            <ListItem>
66                                <PropertyName>ComputerSerial</PropertyName>
67                            </ListItem>
68                            <ListItem>
69                                <PropertyName>MonitorModel</PropertyName>
70                            </ListItem>
71                            <ListItem>
72                                <PropertyName>MonitorSerial</PropertyName>
73                            </ListItem>
74                        </ListItems>
75                    </ListEntry>
76                </ListEntries>
77            </ListControl>
78        </View>
79    </ViewDefinitions>
80</Configuration>

The following example demonstrates how those five properties default to a table and only those five properties are displayed when piping to Format-List unless a wildcard or other properties are specified via the Property parameter.

1$CimSession = New-CimSession -ComputerName DC01, SQL17, WEB01
2Get-MrMonitorInfo -CimSession $CimSession
3Get-MrMonitorInfo -CimSession $CimSession | Format-List
4Get-MrMonitorInfo -CimSession $CimSession | Format-List -Property *

prequel1-ironscripter6a.jpg

If you're not already, you should consider competing in the Iron Scripter prequel events and the official Iron Scripter competition if you're attending the PowerShell + DevOps Global Summit 2018. Competing isn't about playing a game, winning, or losing. It's about having fun while learning real world skills with a little friendly competition. I've found that I learn something every time I work through a scripting games scenario. The two big takeaways for me during this event are how to update a read-only property and splatting the same command twice or double-splatting as I'll call it as shown in the following example.

 1$Params = @{
 2    ErrorAction = 'SilentlyContinue'
 3    ErrorVariable = 'Problem'
 4}
 5
 6if ($PSBoundParameters.CimSession) {
 7    $Params.CimSession = $CimSession
 8}
 9
10$CSParams = @{
11    ClassName = 'Win32_ComputerSystem'
12    Property = @('Name', 'Manufacturer', 'Model')
13}
14
15$BIOSParams = @{
16    ClassName = 'Win32_BIOS'
17    Property = 'SerialNumber'
18}
19
20$MonitorParams = @{
21    ClassName = 'WmiMonitorID'
22    Namespace = 'root/WMI'
23    Property = @('ManufacturerName', 'UserFriendlyName', 'ProductCodeID', 'SerialNumberID', 'WeekOfManufacture', 'YearOfManufacture')
24}
25
26Get-CimInstance @Params @CSParams
27Get-CimInstance @Params @BIOSParams
28Get-CimInstance @Params @MonitorParams

In the end, I decided not to splat these commands twice in my function, but it's nice to know something like that is possible and probably not something I would have thought of if I hadn't been trying to solve this particular iron scripter puzzle.

Last, but not least, your solution isn't complete until you've added it to a source control system such as Git and shared it with the community on a site such as GitHub.

1git init
2git add .
3git commit -m 'Initial commit of MrIronScripter PowerShell module'
4git remote add origin https://github.com/mikefrobbins/IronScripter.git
5git remote -v
6git push origin master

prequel1-ironscripter7a.jpg

Prior to running the commands shown in the previous example, I created an empty and uninitialized repository on GitHub named IronScripter.

Publishing it to the PowerShell Gallery so others can easily install it probably wouldn't be a bad idea either.

1$API = '******My-API-Key******'
2Publish-Module -Name MrIronScripter -Repository PSGallery -NuGetApiKey $API
3Find-Module -Name MrIronScripter

prequel1-ironscripter8a.jpg

The code in this blog article can be installed from the PowerShell Gallery using the Install-Module function which is part of the PowerShellGet module that ships with PowerShell version 5.0 and higher. The PowerShellGet module can be downloaded and installed on PowerShell 3.0+.

1Install-Module -Name MrIronScripter -Force
2Get-Module -Name MrIronScripter -ListAvailable

prequel1-ironscripter9a.jpg

In the future, I plan to start using Plaster instead of my functions to create script modules and functions. I also plan to look into using platyPS for creating MAML based help as an alternative to comment based help.

If you want to learn how to write PowerShell functions and script modules from a former winner of the advanced category in the scripting games and a multiyear recipient of both Microsoft’s MVP and SAPIEN Technologies MVP award, then you should definitely consider attending my Writing award winning PowerShell functions and script modules session at the PowerShell + DevOps Global Summit 2018.

µ