private/cmhealthutils.ps1

# Updated for 0.4.0.1

function Import-CmHealthSettings {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        [parameter()][string] $Primary = "$($env:TEMP)\cmhealth.json",
        [parameter()][string] $Default = "$(Split-Path $(Get-Module cmhealth).Path)\reserve\cmhealth.json"
    )
    try {
        if (Test-Path $Primary) {
            Write-Log -Message "loading from: $Primary"
            $result = Get-Content -Path $Primary | ConvertFrom-Json
        } elseif (Test-Path $Default) {
            Write-Log -Message "loading from: $Default"
            $result = Get-Content -Path $Default | ConvertFrom-Json
        } else {
            throw "cmhealth.json was not found"
        }
    }
    catch {
        Write-Error $_.Exception.Message
    }
    finally {
        $result
    }
}

function New-CmHealthConfig {
    [CmdletBinding()]
    [OutputType()]
    param(
        [parameter(Mandatory=$False)][string]$Path = "$($env:TEMP)\cmhealth.json"
    )
    Write-Log -Message "Creating default cmhealth settings file at $Path"
    $mpath = Split-Path $(Get-Module cmhealth).Path
    $rpath = "$($mpath)\reserve"
    Write-Log -Message "source path is $rpath"
    $configFile = "$($rpath)\cmhealth.json"
    Write-Log -Message "destination path is $Path"
    Copy-Item -Path $configFile -Destination $Path -Force | Out-Null
    Write-Log -Message "cmhealth settings file saved as: $($Path)"
}

function Get-CmHealthDefaultValue {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        [parameter(Mandatory)][ValidateNotNullOrEmpty()][string] $KeySet,
        [parameter(Mandatory)][ValidateNotNullOrEmpty()] $DataSet
    )
    try {
        $keydef = $KeySet -split ':'
        if ($keydef.Count -gt 1) {
            $keyname = $keydef[0]
            $value   = $keydef[1]
            $result  = $DataSet."$keyname"."$value"
        } else {
            $result = $DataSet."$keydef"
        }
    }
    catch {
        Write-Error $_.Exception.Message
    }
    finally {
        Write-Output $result
    }
}

function Get-CmHealthLastTestSet {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [parameter(Mandatory=$False)][string] $FilePath = "$($env:TEMP)\cmhealth-lastrun.txt"
    )
    if (Test-Path $FilePath) {
        Write-Log -Message "importing test selection from $FilePath"
        Write-Output $(Get-Content -Path $FilePath)
    } else {
        Write-Warning "Test history file not found: $FilePath"
    }
}

function Set-CmHealthLastTestSet {
    [CmdletBinding()]
    [OutputType()]
    param(
        [parameter(Mandatory=$True)][string[]] $TestNames,
        [parameter(Mandatory=$False)][string] $FilePath = "$($env:TEMP)\cmhealth-lastrun.txt"
    )
    Write-Log -Message "saving test selection to $FilePath"
    $TestNames | Out-File -FilePath $FilePath -Force
}

function Get-CmSqlQueryResult {
    [CmdletBinding()]
    [OutputType()]
    param (
        [parameter(Mandatory=$True)][ValidateNotNullOrEmpty()][string] $Query,
        [parameter(Mandatory=$True)] $Params
    )
    if ($null -ne $Params.Credential) {
        Write-Log -Message "submitting query with credentials"
        $result = @(Invoke-DbaQuery -SqlInstance $Params.SqlInstance -Database $Params.Database -Query $Query -SqlCredential $Params.Credential)
    } else {
        Write-Log -Message "submitting query without credentials"
        $result = @(Invoke-DbaQuery -SqlInstance $Params.SqlInstance -Database $Params.Database -Query $Query)
    }
    $result
}

function Get-WmiQueryResult {
    [CmdletBinding()]
    [OutputType()]
    param (
        [parameter(Mandatory=$True)][string] $ClassName,
        [parameter(Mandatory=$False)][string] $Query = "",
        [parameter(Mandatory=$False)][string] $NameSpace = "root\cimv2",
        [parameter(Mandatory=$True)] $Params
    )
    if (![string]::IsNullOrEmpty($Params.Credential)) {
        Write-Log -Message "submitting WMI query with explicit credentials"
        Write-Log -Message "classname = $ClassName"
        $cs1 = New-CimSession -Credential $Params.Credential -Authentication Negotiate -ComputerName $Params.ComputerName -ErrorAction Stop
        if ([string]::IsNullOrEmpty($Query)) {
            $result = @(Get-CimInstance -CimSession $cs1 -ClassName $ClassName -Namespace $Namespace -ErrorAction Stop)
        } else {
            Write-Log -Message "query = $Query"
            $result = @(Get-CimInstance -CimSession $cs1 -ClassName $ClassName -Namespace $Namespace -Filter $Query -ErrorAction Stop)
        }
        $cs1 | Remove-CimSession
    } else {
        Write-Log -Message "submitting WMI query with implicit credentials"
        Write-Log -Message "classname = $ClassName"
        if ([string]::IsNullOrEmpty($Query)) {
            Write-Log -Message "no query. classname = $ClassName. namespace = $Namespace"
            [array]$result = Get-CimInstance -ClassName $ClassName -Namespace $Namespace -ErrorAction Stop
        } else {
            Write-Log -Message "query = $Query"
            [array]$result = Get-CimInstance -ClassName $ClassName -Namespace $Namespace -Filter $Query -ErrorAction Stop
        }
    }
    $result
}
function Get-RunTime {
    param (
        [parameter(Mandatory=$True)][datetime] $BaseTime
    )
    $NowTime = (Get-Date)
    $runTime = $(New-TimeSpan -Start $BaseTime -End $NowTime)
    $ret = $("{0}h:{1}m:{2}s:{3}ms" -f $($runTime | Foreach-Object {$_.Hours,$_.Minutes,$_.Seconds,$_.Milliseconds}))
    Write-Output $ret
}

function Convert-DecErrToHex {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        [parameter(Mandatory=$True)]$DecimalNumber
    )
    $n = [math]::Abs($DecimalNumber)
    Write-Output $('0x'+(++$n).ToString('X'))
}

# original from http://vcloud-lab.com/entries/powershell/powershell-get-registry-value-data
function Get-RegistryValueData {
    [CmdletBinding(SupportsShouldProcess=$True,
        ConfirmImpact='Medium',
        HelpURI='http://vcloud-lab.com')]
    [OutputType()]
    Param ( 
        [parameter(Position=0, ValueFromPipeline=$True, ValueFromPipelineByPropertyName=$True)]
        [alias('C')]
        [String[]]$ComputerName = '.',
        [Parameter(Position=1, Mandatory=$True, ValueFromPipelineByPropertyName=$True)] 
        [alias('Hive')]
        [ValidateSet('ClassesRoot', 'CurrentUser', 'LocalMachine', 'Users', 'CurrentConfig')]
        [String]$RegistryHive = 'LocalMachine',
        [Parameter(Position=2, Mandatory=$True, ValueFromPipelineByPropertyName=$True)]
        [alias('KeyPath')]
        [String]$RegistryKeyPath = 'SYSTEM\CurrentControlSet\Services\USBSTOR',
        [parameter(Position=3, Mandatory=$True, ValueFromPipelineByPropertyName=$true)]
        [alias('Value')]
        [String]$ValueName = 'Start'
    )
    Begin {
        $RegistryRoot= "[{0}]::{1}" -f 'Microsoft.Win32.RegistryHive', $RegistryHive
        try {
            $RegistryHive = Invoke-Expression $RegistryRoot -ErrorAction Stop
        }
        catch {
            Write-Log -Message "incorrect registry hive referenced: $RegistryHive does not exist" -Category Warning -Show
        }
    }
    Process {
        Foreach ($Computer in $ComputerName) {
            Write-Log -Message "verifying connectivity to $computer"
            if ($computer -eq '.') {
                $reg = [Microsoft.Win32.RegistryKey]::OpenBaseKey($RegistryHive, 'default')
                $key = $reg.OpenSubKey($RegistryKeyPath)
                $Data = $key.GetValue($ValueName)
                [pscustomobject]@{
                    Computer = $Computer
                    RegistryValueName = "$RegistryKeyPath\$ValueName"
                    RegistryValueData = $Data
                }
            } elseif (Test-Connection $computer -Count 2 -Quiet) {
                Write-Log -Message "keypath = $RegistryKeyPath - value = $ValueName"
                $reg = [Microsoft.Win32.RegistryKey]::OpenRemoteBaseKey($RegistryHive, $Computer)
                $key = $reg.OpenSubKey($RegistryKeyPath)
                $Data = $key.GetValue($ValueName)
                [pscustomobject]@{
                    Computer = $Computer
                    RegistryValueName = "$RegistryKeyPath\$ValueName"
                    RegistryValueData = $Data
                }
            }
            else {
                Write-Log -Message "$Computer not reachable" -Category Warning -Show
            }
        }
    }
    End {
        #[Microsoft.Win32.RegistryHive]::ClassesRoot
        #[Microsoft.Win32.RegistryHive]::CurrentUser
        #[Microsoft.Win32.RegistryHive]::LocalMachine
        #[Microsoft.Win32.RegistryHive]::Users
        #[Microsoft.Win32.RegistryHive]::CurrentConfig
    }
}

# returns 4-digit ConfigMgr site version build number (e.g. "9045")
function Get-CmBuildNumber {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        [parameter(Mandatory=$False)][string]$ComputerName = '.'
    )
    $cmv = Get-RegistryValueData -ComputerName $ComputerName -RegistryHive LocalMachine -RegistryKeyPath SOFTWARE\Microsoft\SMS\Setup -ValueName 'Version'
    Write-Output $($cmv | Select-Object -ExpandProperty RegistryValueData)
}

function Get-CmVersionName {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [parameter(Mandatory=$True)][string] $Version
    )
    Write-Log -Message "querying configuration manager version name"
    $mpath = $(Get-Module cmhealth -ListAvailable).Path | Select-Object -First 1
    $fpath = $(Join-Path -Path $(Split-Path $mpath) -ChildPath "private\buildnumbers_cm.csv")
    Write-Log -Message "reading file $fpath"
    if (Test-Path $fpath) {
        $csvdata = Import-Csv -Path $fpath
        $build = $csvdata | Where-Object {$_.Build -eq $Version} | Select-Object -ExpandProperty Name
        Write-Output $build
    } else {
        Write-Log -Message "file not found! $fpath" -Category Warning
    }
}

function Write-Log {
    param (
        [parameter(Mandatory=$False)][string]$Message = "",
        [parameter(Mandatory=$False)][string][ValidateSet('Info','Warning','Error')]$Category = "Info",
        [parameter(Mandatory=$False)][switch]$Show,
        [parameter(Mandatory=$False)][switch]$ClearLog
    )
    $msg = "$(Get-Date -f 'yyyy-MM-dd hh:mm:ss') - $Category - $Message"
    if ($ClearLog) {
        $msg | Out-File -FilePath $LogFile -Force
    } else {
        $msg | Out-File -FilePath $LogFile -Append
    }
    if ($Show) { 
        switch ($Category) {
            'Error' { Write-Host $msg -ForegroundColor Red }
            'Warning' { Write-Host $msg -ForegroundColor Yellow }
            Default { Write-Host $msg -ForegroundColor Cyan }
        }
    }
}

function Get-WindowsBuildNumber {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [parameter(Mandatory=$True)][string] $Version
    )
    switch ($Version) {
        '10.0.10240' { '1507' }
        '10.0.10586' { '1511' }
        '10.0.14393' { '1607' }
        '10.0.15063' { '1703' }
        '10.0.16299' { '1709' }
        '10.0.17134' { '1803' }
        '10.0.17763' { '1809' }
        '10.0.18362' { '1903' }
        '10.0.18363' { '1909' }
        '10.0.19041' { '2004' }
        '10.0.19042' { '20H2' }
        '10.0.19043' { '21H1' }
        '10.0.19044' { '21H2' }
        '10.0.22000' { '21H1' }
        Default { 'unknown' }
    }
}

<#
CREDIT to Trevor Jones for this function as part of the script that queries
site status messages. Refer to https://smsagent.blog/2015/07/22/retrieving-configmgr-status-messages-with-powershell/
#>

function Get-StatusMessage {
    [CmdletBinding()]
    [OutputType()]
    param (
        $SmsMsgsPath,
        $iMessageID,
        [ValidateSet("srvmsgs.dll","provmsgs.dll","climsgs.dll")]$DLL,
        [ValidateSet("Informational","Warning","Error")]$Severity,
        $InsString1,
        $InsString2,
        $InsString3,
        $InsString4,
        $InsString5,
        $InsString6,
        $InsString7,
        $InsString8,
        $InsString9,
        $InsString10
    )
     
    if ($DLL -eq "srvmsgs.dll")    { $stringPathToDLL = "$SMSMSGSLocation\srvmsgs.dll" }
    if ($DLL -eq "provmsgs.dll") { $stringPathToDLL = "$SMSMSGSLocation\provmsgs.dll" }
    if ($DLL -eq "climsgs.dll") { $stringPathToDLL = "$SMSMSGSLocation\climsgs.dll" }
    Write-Verbose "DLL = $stringPathToDLL"

    Write-Verbose "Loading Status Message Lookup DLL into memory and get pointer to memory"
    $ptrFoo = $Win32LoadLibrary::LoadLibrary($stringPathToDLL.ToString())
    $ptrModule = $Win32GetModuleHandle::GetModuleHandle($stringPathToDLL.ToString()) 
     
    if ($Severity -eq "Informational") { $code = 1073741824 }
    if ($Severity -eq "Warning") { $code = 2147483648 }
    if ($Severity -eq "Error") { $code = 3221225472 }
     
    $result = $Win32FormatMessage::FormatMessage($flags, $ptrModule, $Code -bor $iMessageID, 0, $stringOutput, $sizeOfBuffer, $stringArrayInput)
    if ($result -gt 0) {
        Write-Verbose "result is non-zero"
        # Add insert strings to message
        $objMessage = New-Object System.Object
        $objMessage | Add-Member -type NoteProperty -name MessageString -value $stringOutput.ToString().Replace("%11","").Replace("%12","").Replace("%3%4%5%6%7%8%9%10","").Replace("%1",$InsString1).Replace("%2",$InsString2).Replace("%3",$InsString3).Replace("%4",$InsString4).Replace("%5",$InsString5).Replace("%6",$InsString6).Replace("%7",$InsString7).Replace("%8",$InsString8).Replace("%9",$InsString9).Replace("%10",$InsString10)
    }
    $objMessage
}

<#
CREDIT to Trevor Jones for this function as part of the script that queries
site status messages. Refer to https://smsagent.blog/2015/07/22/retrieving-configmgr-status-messages-with-powershell/
The only real modification was to replace the ADO.NET code using module DbaTools: Invoke-DbaQuery
#>


function Get-SiteStatusMessages {
    [CmdletBinding()]
    [OutputType()]
    param ($Params)
    try {
        # get installation path to determine smsmsgs DLL path
        $site =Get-CimInstance -ClassName SMS_Site -ComputerName $Params.ComputerName -Namespace "root/sms/site_$($Params.SiteCode)"
        if ($null -ne $site.InstallDir) {
            $SMSMSGSLocation = "$($site.InstallDir)\bin\X64\system32\smsmsgs"
        } else {
            throw "unable to get installation path"
        }
        $Query = "
select smsgs.RecordID,
CASE smsgs.Severity
WHEN -1073741824 THEN 'Error'
WHEN 1073741824 THEN 'Informational'
WHEN -2147483648 THEN 'Warning'
ELSE 'Unknown'
END As 'SeverityName',
case smsgs.MessageType
WHEN 256 THEN 'Milestone'
WHEN 512 THEN 'Detail'
WHEN 768 THEN 'Audit'
WHEN 1024 THEN 'NT Event'
ELSE 'Unknown'
END AS 'Type',
smsgs.MessageID, smsgs.Severity, smsgs.MessageType, smsgs.ModuleName,modNames.MsgDLLName, smsgs.Component,
smsgs.MachineName, smsgs.Time, smsgs.SiteCode, smwis.InsString1,
smwis.InsString2, smwis.InsString3, smwis.InsString4, smwis.InsString5,
smwis.InsString6, smwis.InsString7, smwis.InsString8, smwis.InsString9,
smwis.InsString10
from v_StatusMessage smsgs
join v_StatMsgWithInsStrings smwis on smsgs.RecordID = smwis.RecordID
join v_StatMsgModuleNames modNames on smsgs.ModuleName = modNames.ModuleName
where smsgs.MachineName = '$($Params.ComputerName)'
and DATEDIFF(hour,smsgs.Time,GETDATE()) < '$TimeInHours'
Order by smsgs.Time DESC
"


        $table = Invoke-DbaQuery -SqlInstance $Params.SqlInstance -Database $Params.Database -Query $Query

#Start PInvoke Code
$sigFormatMessage = @'
[DllImport("kernel32.dll")]
public static extern uint FormatMessage(uint flags, IntPtr source, uint messageId, uint langId, StringBuilder buffer, uint size, string[] arguments);
'@
 
 
$sigGetModuleHandle = @'
[DllImport("kernel32.dll")]
public static extern IntPtr GetModuleHandle(string lpModuleName);
'@
 
 
$sigLoadLibrary = @'
[DllImport("kernel32.dll")]
public static extern IntPtr LoadLibrary(string lpFileName);
'@
 
 
        $Win32FormatMessage = Add-Type -MemberDefinition $sigFormatMessage -Name "Win32FormatMessage" -Namespace Win32Functions -PassThru -Using System.Text
        $Win32GetModuleHandle = Add-Type -MemberDefinition $sigGetModuleHandle -Name "Win32GetModuleHandle" -Namespace Win32Functions -PassThru -Using System.Text
        $Win32LoadLibrary = Add-Type -MemberDefinition $sigLoadLibrary -Name "Win32LoadLibrary" -Namespace Win32Functions -PassThru -Using System.Text
        #End PInvoke Code
 
        $sizeOfBuffer = [int]16384
        $stringArrayInput = {"%1","%2","%3","%4","%5", "%6", "%7", "%8", "%9"}
        $flags = 0x00000800 -bor 0x00000200
        $stringOutput = New-Object System.Text.StringBuilder $sizeOfBuffer 
 
        $StatusMessages = @()
        foreach ($Row in $Table) {
            $Params = @{
                SmsMsgsPath = $SMSMSGSLocation
                iMessageID  = $Row.MessageID
                DLL         = $Row.MsgDLLName
                Severity    = $Row.SeverityName
                InsString1  = $Row.InsString1
                InsString2  = $Row.InsString2
                InsString3  = $Row.InsString3
                InsString4  = $Row.InsString4
                InsString5  = $Row.InsString5
                InsString6  = $Row.InsString6
                InsString7  = $Row.InsString7
                InsString8  = $Row.InsString8
                InsString9  = $Row.InsString9
                InsString10 = $Row.InsString10
            }
            $Message = Get-StatusMessage @params
 
            $StatusMessage = New-Object psobject
            Add-Member -InputObject $StatusMessage -Name Severity -MemberType NoteProperty -Value $Row.SeverityName
            Add-Member -InputObject $StatusMessage -Name Type -MemberType NoteProperty -Value $Row.Type
            Add-Member -InputObject $StatusMessage -Name SiteCode -MemberType NoteProperty -Value $Row.SiteCode
            Add-Member -InputObject $StatusMessage -Name DateTime -MemberType NoteProperty -Value $Row.Time
            Add-Member -InputObject $StatusMessage -Name System -MemberType NoteProperty -Value $Row.MachineName
            Add-Member -InputObject $StatusMessage -Name Component -MemberType NoteProperty -Value $Row.Component
            Add-Member -InputObject $StatusMessage -Name Module -MemberType NoteProperty -Value $Row.ModuleName
            Add-Member -InputObject $StatusMessage -Name MessageID -MemberType NoteProperty -Value $Row.MessageID
            Add-Member -InputObject $StatusMessage -Name Description -MemberType NoteProperty -Value $Message.MessageString
            $StatusMessages += $StatusMessage
        }
        $StatusMessages
    }
    catch {
        Write-Error $_.Exception.Message 
    }
}

function Test-CmHealthModuleVersion {
    [CmdletBinding()]
    [OutputType()]
    param()
    try {
        $mv = Get-Module 'cmhealth' -ListAvailable | Select-Object -First 1 -ExpandProperty Version
        if ($null -ne $mv) {
            $mv = $mv -join '.'
            $fv = Find-Module 'cmhealth' | Select-Object -ExpandProperty Version
            if ([version]$fv -gt [version]$mv) {
                Write-Warning "cmhealth $mv is installed. $fv is available"
            } else {
                Write-Host "cmhealth version $mv is the latest available" -ForegroundColor Cyan
            }
        } else {
            Write-Warning "cmhealth version could not be determined"
        }
    }
    catch {
        Write-Error $_.Exception.Message
    }
}

function Install-DbMaintenanceSolution {
<#
.SYNOPSIS
    Configure DB Maintenance Solution and Scheduling
.DESCRIPTION
    Create new database for Database Maintenance plan, install Ola's solution, create and schedule IndexOptimize task
.PARAMETER SQLInstance
    Name of SQL host instance
.PARAMETER DBName
    Name of new maintenance database
.EXAMPLE
    .\Install-CmDbMaintenanceSolution.ps1 -SQLInstance "cm01.contoso.local" -DBName "dba"
.NOTES
    8/27/2021
    Author: Steve Thompson
#>

    [CmdletBinding()]
    [OutputType()]
    param (
        [parameter(Mandatory=$False)][string]$SQLInstance = "localhost",
        [parameter(Mandatory=$False)][string]$DBName = "DBA"
    )

    # Create a new database on the localhost named DBA
    $param = @{
        SqlInstance = $SQLInstance
        Name = $DBName
        Owner = "sa"
        RecoveryModel = "Simple"
    }
    New-DbaDatabase @param

    # Install Ola Hallengrens Database Maintenance solution using the DBA database
    $param = @{
        SqlInstance = $SQLInstance
        Database = $DBName
        ReplaceExisting =-"InstallJobs"
    }
    Install-DbaMaintenanceSolution @param

    # Create a new SQL Server Agent Job to schedule the custom Agent Task
    $param = @{
        SqlInstance = $SQLInstance
        Job = "OptimizeIndexes"
        Owner = "sa"
        Description = "Ola Hallengren Optimize Indexes"
    }
    New-DbaAgentJob @param

    $sqlcmd = "EXECUTE dbo.IndexOptimize
@Databases = 'USER_DATABASES',
@FragmentationLow = NULL,
@FragmentationMedium = 'INDEX_REORGANIZE,INDEX_REBUILD_ONLINE,INDEX_REBUILD_OFFLINE',
@FragmentationHigh = 'INDEX_REBUILD_ONLINE,INDEX_REBUILD_OFFLINE',
@FragmentationLevel1 = 10,
@FragmentationLevel2 = 40,
@UpdateStatistics = 'ALL',
@OnlyModifiedStatistics = 'Y',
@LogToTable = 'Y'"


    # Create a new SQL Agent Task step with the optimal parameters for MEMCM
    $param = @{
        SqlInstance = $SQLInstance
        Job = "OptimizeIndexes"
        StepName = "Step1"
        Database = $DBName
        Command = $sqlcmd
    }
    New-DbaAgentJobStep @param

    # Optionally, create a schedule to run the SQL Agent Tast once a week on Sunday @ 1:00AM
    $param = @{
        SqlInstance = $SQLInstance
        Job = "OptimizeIndexes"
        Schedule = "RunWeekly"
        FrequencyType = "Weekly"
        FrequencyInterval = "Sunday"
        StartTime = "010000"
        Force = $True
    }
    New-DbaAgentSchedule @param
}