Prelude #2 Solution

The last Chairmans’s challenge was based on a task you might have to face in real life – fixing someone else’s code. Although, that someone may be you! Of course it helps to know what should happen, which the Chairman so nicely provided. When run, the function should have produced output like this:

Expected output

A Solution

Without debating different techniques or commands, here is a solution for a working version of the function.

Function Get-DiskInfo {
    [cmdletbinding()]
    Param(
        [Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelinebyPropertyName)]
        [ValidateNotNullorEmpty()]
        [string[]]$Computername,
        [ValidatePattern("[C-Gc-g]")]
        [string]$Drive = "C",
        [ValidateScript({Test-Path $path})]
        [string]$LogPath = $env:temp
    )
    Begin {
        Write-Verbose "Starting $($myinvocation.mycommand)"
        $filename = "{0}_DiskInfo_Errors.txt" -f (Get-Date -format "yyyyMMddhhmm")
        $errorLog = Join-Path -path $LogPath -ChildPath $filename
    }
    Process {
        foreach ($computer in $computername) {
            Write-Verbose "Getting disk information from $computer for drive $($drive.toUpper())"
            try {
                $data = Get-Volume -DriveLetter $drive -CimSession $computer -ErrorAction Stop
                $data | Select-Object -property DriveLetter,
                @{Name = "SizeGB"; Expression = {$_.size / 1gb -as [int]}},
                @{Name = "FreeGB"; Expression = {$_.SizeRemaining / 1GB}},
                @{Name = "PctFree"; Expression = {($_.SizeRemaining / $_.size) * 100 -as [int]}},
                HealthStatus,
                @{Name = "Computername"; Expression = {$_.PSComputername.toUpper()}}
            }
            catch {
                Add-Content -path $errorlog -Value "[$(Get-Date)] Failed to get disk data for drive $drive from $computer"
                Add-Content -path $errorlog -Value "[$(Get-Date)] $($_.exception.message)"
                $newErrors = $True
            }
        }
    }
    End {
        If ((Test-Path -path $errorLog) -AND $newErrors) {
            Write-Warning "Errors have been logged to $errorlog"
        }

        Write-Verbose "Ending $($myinvocation.MyCommand)"
    }
}

The Chairman will let go through the code and compare it to your solution.

A Pester Test

Of course, once modified an Iron Scripter will ensure that any future changes won’t break the code. For that, a Pester test is the best choice of defensive weapon. Here is a test file that is assumed to be in the same directory as the script file.

. $psscriptroot\prequel-2.ps1
Describe Get-DiskInfo {

    Mock Get-Date {
        return "201808121230"
    } -ParameterFilter {$format -eq "yyyyMMddhhmm"}

    Mock Get-Volume {

        $result = [pscustomobject]@{
            DriveLetter    = "C"
            Size           = 512GB
            SizeRemaining  = 99.12345GB
            HealthStatus   = "Healthy"
            PSComputername = "SERVERA"
        }
        return $result
    } -ParameterFilter {$CimSession -match "SERVERA"}

    Mock Get-Volume {

        $result = [pscustomobject]@{
            DriveLetter    = "C"
            Size           = 256GB
            SizeRemaining  = 128GB
            HealthStatus   = "Healthy"
            PSComputername = "SERVERB"
        }
        return $result
    } -ParameterFilter {$CimSession -match "SERVERB"}
    Mock Get-Volume {

        $result = [pscustomobject]@{
            DriveLetter    = "D"
            Size           = 512GB
            SizeRemaining  = 100GB
            HealthStatus   = "Healthy"
            PSComputername = "SERVERA"
        }
        return $result
    } -ParameterFilter {$CimSession -match "SERVERA" -AND $Drive -eq 'D'}
    Mock Get-Volume {
        #write-host "Offline mock" -ForegroundColor magenta
        write-Error "Failed to connect to Offline"
    } -parameterfilter {$cimsession -match "Offline"}

    Context "Input" {

        $cmd = Get-Command -Name Get-DiskInfo
        $attributes = $cmd.parameters["computername"].attributes | where-object {$_.typeid.name -eq 'parameterattribute'}
        It "Should have a mandatory parameter for the computername" {
            $attributes.Mandatory | Should Be $True
        }

        It "Should accept parameter input for the computername" {
            {Get-Diskinfo -Computername SERVERA} | Should beTrue
        }

        It "Should accept multiple computernames from the parameter" {
            $r = Get-Diskinfo -Computername SERVERA, SERVERB
            $r.count | Should be 2
        }
        It "Should accept positional parameter input for the computername" {
            $attributes.position | Should be 0
            {get-Diskinfo SERVERA} | Should beTrue
        }

        It "Should accept pipeline input by property name for the computername" {
            $attributes.ValueFromPipelinebyPropertyName | Should Be $True
            {[pscustomobject]@{Computername = "servera"}  | Get-Diskinfo } | Should beTrue
            $r = [pscustomobject]@{Computername = "servera"}, [pscustomobject]@{Computername = "servera"}  | Get-Diskinfo
            $r.count  | Should be 2
        }

        It "Should accept pipeline input by value for the computername" {
            $attributes.ValueFromPipeline | Should Be $True
            {"SERVERA" | Get-DiskInfo} | Should BeTrue
        }

        It "Should only accept drives between C and G" {
            $driveparam = $cmd.parameters["drive"].attributes | where-object {$_.typeid.name -eq 'ValidatePatternAttribute'}
            $driveparam.RegexPattern | Should match "c-g"
            {Get-DiskInfo -computername SERVERA -Drive D} | Should BeTrue
            {Get-DiskInfo -computername SERVERA -Drive H} | Should Throw

        }

        It "Should fail with a bad computername" {
            $r = Get-Diskinfo -Computername "offline" -WarningAction "silentlyContinue"
            $r | Should BeFalse
        }

    }
    Context "Output" {

        $test = Get-DiskInfo -computername ServerA

        It "Should call Get-Volume" {
            Assert-MockCalled 'Get-Volume'
        }
        It "Should write an object to the pipeline with the computername SERVERA" {
            $test.computername | Should Be "SERVERA"
        }

        It "Should write an object to the pipeline with a FreeGB value of a [double] for SERVERA" {
            $test.freeGB | Should BeOfType "double"
        }

        It "Should write an object to the pipeline with a SizeGB value of an [int] for SERVERA" {
            $test.SizeGB | Should BeofType "int"
            $test.SizeGB | Should Be 512
        }

        It "Should write an object to the pipeline with a PctFree value of an [int] for SERVERA" {
            $test.PctFree | Should BeOfType "int"
            $test.PctFree | Should Be 19
        }

        It "should write an object to the pipeline with a HealthStatus value of 'Healthy' for SERVERA" {
            $test.HealthStatus | Should Be "Healthy"
        }
    }
    Context "Error Handling" {

        It "Should fail with a bad folder for the log" {
            {Get-DiskInfo -Computername foo -LogPath TestDrive:\foo -ErrorAction stop } | Should Throw
        }
        It "Should create an error log with a name that includes YearMonthDayHourMinute" {
            Get-DiskInfo -Computername Offline -LogPath TestDrive: -WarningVariable w -WarningAction SilentlyContinue
            $log = Get-Item TestDrive:\*.txt

            $w | Should Be $true
            $w | Should Match "201808121230_DiskInfo_Errors"
            $log.length | Should BeGreaterThan 0
            $log.name | Should Match "201808121230"
        }
    }

}

Creating a Pester test is as much of an art as anything so your solution will most likely vary. But when run you should get a result like this.

A Pester test result

If your Pester skills are soft, the Chairman recommends a hearty workout regimen. If you require assistance, seek out masters in the forums at PowerShell.org. In the mean time keep flexing your PowerShell skills as another Chairman’s prelude challenge looms.