DSCResources/cApplication/cApplication.psm1

Enum Ensure{
    Absent
    Present
}

function Get-TargetResource
{
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param
    (
        [parameter(Mandatory = $false)]
        [ValidateSet("Present","Absent")]
        [System.String]
        $Ensure = 'Present',

        [parameter(Mandatory = $true)]
        [System.String]
        $Name,

        [parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [System.String]
        $InstallerPath,

        [System.String]
        $ProductId,

        [System.Boolean]
        $Fuzzy = $false
    )

    # Get Application info
    # $ProductId take priority over $Name
    if($ProductId){
        $Program = Get-InstalledProgram -ProductId $ProductId
    }
    else{
        $Program = Get-InstalledProgram -Name $Name -Fuzzy:$Fuzzy
    }

    if(-not $Program){
        Write-Verbose ('The application "{0}" is not installed.' -f $Name)
        $returnValue = @{
            Ensure = [Ensure]::Absent
            Name = ''
            InstallerPath = $InstallerPath
            Installed = $false
        }
        return $returnValue
    }
    else{
        Write-Verbose ('The application "{0}" is installed.' -f $Program.DisplayName)
        $ProgramInfo = @{
            Ensure = 'Present'
            Name = $Program.DisplayName
            ProductId = $Program.PSChildName
            Version = $Program.DisplayVersion
            Publisher = $Program.Publisher
            InstallerPath = $InstallerPath
            UninstallString = $Program.UninstallString
            Installed = $true
        }
        return $ProgramInfo
    }
} # end of Get-TargetResource


function Test-TargetResource
{
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param
    (
        [parameter(Mandatory = $false)]
        [ValidateSet("Present","Absent")]
        [System.String]
        $Ensure = 'Present',

        [parameter(Mandatory = $true)]
        [System.String]
        $Name,

        [parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [System.String]
        $InstallerPath,

        [System.String]
        $ProductId,

        [System.Boolean]
        $Fuzzy = $false,

        [System.Boolean]
        $NoRestart = $false,

        [System.String]
        $Version,

        [System.String]
        $Arguments,

        [System.String]
        $ArgumentsForUninstall,

        [System.Boolean]
        $UseUninstallString = $true,

        [PSCredential]
        [System.Management.Automation.Credential()]
        $Credential,

        # Return codes 1641 and 3010 indicate success when a restart is requested per installation
        [ValidateNotNullOrEmpty()]
        [UInt32[]]
        $ReturnCode = @( 0, 1641, 3010 ),

        [UInt32]
        $TimeoutSec = 900,

        [System.String]
        $FileHash,

        [ValidateSet('SHA1', 'SHA256', 'SHA384', 'SHA512', 'MD5', 'RIPEMD160')]
        [String]
        $HashAlgorithm = 'SHA256',

        [string]
        $PreAction,

        [string]
        $PostAction,

        [string]
        $PreCopyFrom,

        [string]
        $PreCopyTo
    )

    $private:GetParam = @{
        Ensure = $Ensure
        Name = $Name
        InstallerPath = $InstallerPath
        ProductId = $ProductId
        Fuzzy = $Fuzzy
    }

    $ProgramInfo = Get-TargetResource @GetParam -ErrorAction Stop

    if($Ensure -eq 'Absent'){
        switch ($ProgramInfo.Ensure) {
            'Absent' {
                Write-Verbose ('Match desired state & current state. Return "True"')
                return $true
            }
            'Present' {
                Write-Verbose ('Missmatch desired state & current state. Return "False"')
                return $false
            }
            Default {
                Write-Error 'Test failed (unexpected error)'
            }
        }
    }
    else{
        switch ($ProgramInfo.Ensure) {
            'Absent' {
                Write-Verbose ('Missmatch desired state & current state. Return "False"')
                return $false
            }
            'Present' {
                if($Version){
                    if($Version -ne $ProgramInfo.Version){
                        Write-Verbose ('The application "{0}" is installed. but NOT match your desired version. (Desired version: "{1}", Installed version: "{2}")' -f $Name, $Version, $ProgramInfo.Version)
                        Write-Verbose ('Missmatch desired state & current state. Return "False"')
                        return $false
                    }
                }

                Write-Verbose ('Match desired state & current state. Return "True"')
                return $true
            }
            Default {
                Write-Error 'Test failed (unexpected error)'
            }
        }
    }
} # end of Test-TargetResource

function Set-TargetResource
{
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $false)]
        [ValidateSet("Present","Absent")]
        [System.String]
        $Ensure = 'Present',

        [parameter(Mandatory = $true)]
        [System.String]
        $Name,

        [parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [System.String]
        $InstallerPath,

        [System.String]
        $ProductId,

        [System.Boolean]
        $Fuzzy = $false,

        [System.Boolean]
        $NoRestart = $false,

        [System.String]
        $Version,

        [System.String]
        $Arguments,

        [System.String]
        $ArgumentsForUninstall,

        [System.Boolean]
        $UseUninstallString = $true,

        [PSCredential]
        [System.Management.Automation.Credential()]
        $Credential,

        # Return codes 1641 and 3010 indicate success when a restart is requested per installation
        [ValidateNotNullOrEmpty()]
        [UInt32[]]
        $ReturnCode = @( 0, 1641, 3010 ),

        [UInt32]
        $TimeoutSec = 900,

        [System.String]
        $FileHash,

        [ValidateSet('SHA1', 'SHA256', 'SHA384', 'SHA512', 'MD5', 'RIPEMD160')]
        [String]
        $HashAlgorithm = 'SHA256',

        [string]
        $PreAction,

        [string]
        $PostAction,

        [string]
        $PreCopyFrom,

        [string]
        $PreCopyTo
    )

    if(($Ensure -eq 'Absent') -and (!$UseUninstallString) -and (!$InstallerPath)){
        Write-Error ("InstallerPath is not specified. Skip Set-Configuration.")
        return
    }
    elseif(($Ensure -eq 'Present') -and (!$InstallerPath)){
        Write-Error ("InstallerPath is not specified. Skip Set-Configuration.")
        return
    }

    #PreCopy
    if($PreCopyFrom -and $PreCopyTo){
        $UsePreCopy = $true
        Write-Verbose ('PreCopy From:"{0}" To:"{1}"' -f $PreCopyFrom.OriginalString, $PreCopyTo.OriginalString)
        Get-RemoteFile -Path $PreCopyFrom -DestinationFolder $PreCopyTo -Credential $Credential -TimeoutSec $TimeoutSec -Force -ErrorAction Stop | Out-Null
    }

    #PreAction
    ExecuteScriptBlock -ScriptBlockString $PreAction -ErrorAction Continue

    $private:TempFolder = $env:TEMP
    $private:UseWebFile = $false
    $private:Installer = ''
    $private:strInOrUnin = ''
    $private:msiOpt = ''
    $private:Arg = New-Object 'System.Collections.Generic.List[System.String]'
    $private:tmpDriveName = [Guid]::NewGuid()

    $private:GetParam = @{
        Ensure = $Ensure
        Name = $Name
        InstallerPath = $InstallerPath
        ProductId = $ProductId
        Fuzzy = $Fuzzy
    }
    $private:ProgramInfo = Get-TargetResource @GetParam -ErrorAction Stop

    try{
        if($Ensure -eq 'Absent'){
            Write-Verbose ('Ensure = "Absent". Try to uninstall an application.')
            $strInOrUnin = 'Uninstall'
            $msiOpt = 'x'
            $Arguments = $ArgumentsForUninstall

            if($UseUninstallString){
                Write-Verbose ('Use UninstallString for uninstall. ("{0}")' -f $ProgramInfo.UninstallString)
                $UseWebFile = $false
                if($ProgramInfo.UninstallString -match '^(?<path>.+\.[a-z]{3})(?<args>.*)'){
                    $Installer = $Matches.path
                    $Arg.Add($Matches.args)
                }
                else{
                    throw ("Couldn't parse UninstallString.")
                }
            }
        }
        else{
            Write-Verbose ('Ensure = "Present". Try to install an application.')
            $strInOrUnin = 'Install'
            $msiOpt = 'i'
        }

        if(($Ensure -eq 'Absent') -and $UseUninstallString){
        }
        else{
            Write-Verbose ('Use Installer ("{0}") for {1}. (if the path of installer as http/https/ftp. will download it)' -f $InstallerPath, $strInOrUnin)
            if($InstallerPath -match '^msiexec[.exe]?'){
                #[SpecialTreat]If specified 'msiexec.exe', replace 'C:\Windows\System32\msiexec.exe'
                $InstallerPath = (Join-Path $env:windir '\system32\msiexec.exe')
            }
            $private:tmpPath = [System.Uri]$InstallerPath
            if($tmpPath.IsLoopback -or $tmpPath.IsUnc){
                Write-Verbose ('"{0}" is local file or remote unc file.' -f $tmpPath.LocalPath)
                $UseWebFile = $false
                if($PSBoundParameters.Credential){
                    New-PSDrive -Name $tmpDriveName -PSProvider FileSystem -Root (Split-Path $tmpPath.LocalPath) -Credential $Credential -ErrorAction Stop | Out-Null
                }
                $Installer = $tmpPath.LocalPath
            }
            else{
                $UseWebFile = $true
                $Installer = (Get-RemoteFile -Path $InstallerPath -DestinationFolder $TempFolder -Credential $Credential -TimeoutSec $TimeoutSec -Force -PassThru -ErrorAction Stop)
            }
            
            if($FileHash){
                if(-not (Assert-FileHash -Path $Installer -FileHash $FileHash -Algorithm $HashAlgorithm)){
                    throw ("File '{0}' does not match expected hash value" -f $Installer)
                }
                else{
                    Write-Verbose ("Hash check passed")
                }
            }
        }

        $Arg.Add($Arguments)
        if(-not (Test-Path $Installer -PathType Leaf)){
            throw ("Installer file not found. ('{0}')" -f $Installer)
        }

        if([System.IO.Path]::GetExtension($Installer) -eq '.msi'){
            $Arg.Insert(0, ('/{0} "{1}"' -f $msiOpt,$Installer))
            Write-Verbose ("{2} start. Installer:'{0}', Args:'{1}'" -f 'msiexec.exe', $Arg, $strInOrUnin)
            $ExitCode = Start-Command -FilePath 'msiexec.exe' -ArgumentList $Arg -Timeout ($TimeoutSec * 1000) -ErrorAction Stop
            Write-Verbose ("{1} end. Exitcode: '{0}'" -f $ExitCode, $strInOrUnin)
        }
        else{
            Write-Verbose ("{2} start. Installer:'{0}', Args:'{1}'" -f $Installer, $Arg, $strInOrUnin)
            $ExitCode = Start-Command -FilePath $Installer -ArgumentList $Arg -Timeout ($TimeoutSec * 1000) -ErrorAction Stop
            Write-Verbose ("{1} end. Exitcode: '{0}'" -f $ExitCode, $strInOrUnin)
        }

        if (-not ($ReturnCode -contains $ExitCode)){
            throw ("The exit code {0} was not expected. Configuration is likely not correct" -f $ExitCode)
        }
        else{
            Write-Verbose ('{0} process exited successfully' -f $strInOrUnin)
        }
        
        if(-not $NoRestart){
            $private:serverFeatureData = Invoke-CimMethod -Name 'GetServerFeature' -Namespace 'root\microsoft\windows\servermanager' -Class 'MSFT_ServerManagerTasks' -Arguments @{ BatchSize = 256 } -ErrorAction 'Ignore' -Verbose:$false
            $private:registryData = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name 'PendingFileRenameOperations' -ErrorAction 'Ignore'
            if(($serverFeatureData -and $serverFeatureData.RequiresReboot) -or $registryData -or ($exitcode -eq 3010) -or ($exitcode -eq 1641)){
                Write-Verbose "The machine requires a reboot"
                $global:DSCMachineStatus = 1
            }
        }
    }
    catch [Exception]{
        Write-Error $_.Exception.Message
    }
    finally{
        if($UsePreCopy -and (Test-Path $PreCopyTo)){
            Write-Verbose ("Remove Precopied file(s)")
            Remove-Item $PreCopyTo -Force -Recurse | Out-Null
        }
        if($UseWebFile -and (Test-Path $Installer -PathType Leaf)){
            Write-Verbose ("Remove temp files")
            Remove-Item $Installer -Force -Recurse | Out-Null
        }
        if(Get-PSDrive | where {$_.Name -eq $tmpDriveName}){
            Remove-PSDrive -Name $tmpDriveName -Force
        }
    }

    #PostAction
    ExecuteScriptBlock -ScriptBlockString $PostAction -ErrorAction Continue

} # end of Set-TargetResource


function Get-RemoteFile {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true, Position=0)]
        [Alias("Uri")]
        [Alias("SourcePath")]
        [System.Uri[]] $Path, # ダウンロードするファイルパス(URI)

        [Parameter(Mandatory=$true, Position=1)]
        [string]$DestinationFolder, # ダウンロード先フォルダ

        [Parameter()]
        [AllowNull()]
        [pscredential]$Credential,  # 資格情報

        [Parameter()]
        [int]$TimeoutSec = 0,

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [switch]$PassThru
    )
    begin{
        if(-not (Test-Path $DestinationFolder -PathType Container)){
            Write-Verbose ('DestinationFolder Folder "{0}" is not exist. Will create it.' -f $DestinationFolder)
            New-Item $DestinationFolder -ItemType Directory -Force -ErrorAction Stop
        }
    }

    Process{
        foreach($private:tempPath in $Path){
            try{
                $private:OutFile = ''
                $private:valid = $true
                $private:tmpDriveName = [Guid]::NewGuid()

                if($tempPath.IsLoopback -eq $null){
                    $valid = $false
                    throw ("{0} is not valid uri." -f $tempPath)
                }

                # インストーラの場所によって処理分岐(ローカル or 共有フォルダ or Web)
                if($tempPath.IsLoopback -and (!$tempPath.IsUnc)){   # ローカルファイル
                    Write-Verbose ('"{0}" is local file.' -f $tempPath.LocalPath)
                    $valid = $true
                    $OutFile = $tempPath.LocalPath
                    Write-Verbose ("Copy file from '{0}' to '{1}'" -f $tempPath.LocalPath, $DestinationFolder)
                    Copy-Item -Path $tempPath.LocalPath -Destination $DestinationFolder -ErrorAction Stop -Force:$Force -Recurse -PassThru:$PassThru
                }
                elseif($tempPath.IsUnc){ # 共有フォルダ
                    # 資格情報を使う場合は一度ドライブをマップする必要あり
                    if($PSBoundParameters.Credential){
                        New-PSDrive -Name $tmpDriveName -PSProvider FileSystem -Root (Split-Path $tempPath.LocalPath) -Credential $Credential -ErrorAction Stop | Out-Null
                    }
                    # ローカルにコピーする
                    $OutFile = Join-Path $DestinationFolder ([System.IO.Path]::GetFileName($tempPath.LocalPath))
                    if(Test-Path $OutFile -PathType Leaf){
                        if($tempPath.LocalPath -eq $OutFile){
                            if($PassThru){
                                if(Test-Path $OutFile){
                                    Get-Item $OutFile
                                }
                            }
                            continue
                        }
                        elseif($Force){
                            Write-Warning ('"{0}" will be overwritten.'-f $OutFile)
                        }
                        else{
                            $valid = $false
                            throw ("'{0}' is exist. If you want to replace existing file, Use 'Force' switch." -f $OutFile)
                        }
                    }

                    Write-Verbose ("Copy file from '{0}' to '{1}'" -f $tempPath.LocalPath, $DestinationFolder)
                    Copy-Item -Path $tempPath.LocalPath -Destination $DestinationFolder -ErrorAction Stop -Force:$Force -Recurse
                }
                elseif($tempPath.Scheme -match 'http|https|ftp'){
                    # WebからDL
                    $OutFile = Join-Path $DestinationFolder ([System.IO.Path]::GetFileName($tempPath.AbsoluteUri))
                    if(Test-Path $OutFile -PathType Leaf){
                        if($Force){
                            Write-Warning ('"{0}" will be overwritten.'-f $OutFile)
                        }
                        else{
                            $valid = $false
                            throw ("'{0}' is exist. If you want to replace existing file, Use 'Force' switch." -f $OutFile)
                        }
                    }

                    Write-Verbose ("Download file from '{0}' to '{1}'" -f $tempPath.AbsoluteUri, $OutFile)
                    $private:origVerbose = $VerbosePreference; $VerbosePreference = 'SilentlyContinue'
                    Invoke-WebRequest -Uri $tempPath.AbsoluteUri -OutFile $OutFile -Credential $Credential -TimeoutSec $TimeoutSec -ErrorAction stop
                    $VerbosePreference = $origVerbose
                }
                else{
                    $valid = $false
                    throw ("{0} is not valid uri." -f $tempPath)
                }

                if($valid -and $OutFile -and $PassThru){
                    if(Test-Path $OutFile){
                        Get-Item $OutFile
                    }
                }
            }
            catch [Exception]{
                Write-Error $_.Exception.Message
            }
            finally{
                if(Get-PSDrive | where {$_.Name -eq $tmpDriveName}){
                    Remove-PSDrive -Name $tmpDriveName -Force
                }
            }
        }
    }
}

function Assert-FileHash {
    [CmdletBinding()]
    [OutputType([bool])]
    Param(
        [parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            Position = 0
        )]
        [String]
        $Path,

        [parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [String]
        $FileHash,

        [ValidateSet('SHA1', 'SHA256', 'SHA384', 'SHA512', 'MD5', 'RIPEMD160')]
        [String]
        $Algorithm = 'SHA256'
    )

    Process{
        $private:hash = Get-FileHash -Path $Path -Algorithm $Algorithm | select Hash
        if($FileHash -eq $hash.Hash){
            Write-Verbose ('Match file hash of "{1}". ({0})' -f $hash.Hash, $Path)
            return $true
        }
        else{
            Write-Verbose ('Not match file hash of "{1}". ({0})' -f $hash.Hash, $Path)
            return $false
        }
    }
}

function Get-InstalledProgram {
    [CmdletBinding(DefaultParameterSetName='Name')]
    Param(
        [Parameter(Mandatory, ParameterSetName='Name')]
        [string] $Name,
        [Parameter(Mandatory, ParameterSetName='Id')]
        [string] $ProductId,
        [Parameter(ParameterSetName='Name')]
        [switch] $Fuzzy,
        [switch] $Wow64,
        [switch] $FallbackToWow64 = $true
    )
    $local:Program = $null
    switch ($Wow64) {
        $true  {$UninstallReg = "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall"}
        $false {$UninstallReg = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"}
    }

    $local:InstalledPrograms = Get-ChildItem $UninstallReg | % {Get-ItemProperty $_.PSPath} | where {$_.DisplayName}
    
    switch ($PsCmdlet.ParameterSetName) 
    { 
        'Name' {
            if($Fuzzy){
                $Program = $InstalledPrograms | where {$_.DisplayName -match $Name} | select -First 1
            }
            else{
                $Program = $InstalledPrograms | where {$_.DisplayName -eq $Name} | select -First 1
            }
            break
        }
        'Id' {
            $ProductId = Format-ProductId -ProductId $ProductId
            $Program = $InstalledPrograms | where {$_.PSChildName -eq $ProductId} | select -First 1
            break
        }
    } 

    if($Program){
        $Program
    }
    elseif((!$Wow64) -and $FallbackToWow64 -and (Test-Path "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall")){
        Get-InstalledProgram @PSBoundParameters -Wow64
    }
}

function Format-ProductId
{
    [CmdletBinding()]
    [OutputType([String])]
    param
    (
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [String]
        $ProductId
    )

    try
    {
        $private:identifyingNumber = "{{{0}}}" -f [Guid]::Parse($ProductId).ToString().ToUpper()
        return $identifyingNumber
    }
    catch
    {
        Write-Error ("The specified ProductId ({0}) is not a valid Guid" -f $ProductId)
    }
}

function ExecuteScriptBlock
{
    [CmdletBinding()]
    param
    (
        [parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [string]$ScriptBlockString,

        [parameter(Mandatory = $false)]
        [AllowEmptyCollection()]
        [string[]]$Arguments
    )

    if (-not $ScriptBlockString){ return }

    try
    {
        $scriptBlock = [ScriptBlock]::Create($ScriptBlockString).GetNewClosure()
        Write-Verbose ('Execute ScriptBlock')
        if(@($Arguments).Count -ge 1){
            $scriptBlock.Invoke($Arguments) | Out-String -Stream | Write-Verbose
        }
        else{
            $scriptBlock.Invoke() | Out-String -Stream | Write-Verbose
        }
    }
    catch
    {
        throw $_
    }
}

function Start-Command {
    [CmdletBinding()]
    Param(
        [Parameter(Mandatory=$true, Position=0)]
        [string] $FilePath, # 実行ファイル
        [Parameter(Mandatory=$false, Position=1)]
        [string[]]$ArgumentList, # 引数
        [int]$Timeout = [int]::MaxValue # タイムアウト
    )
    $ProcessInfo = New-Object System.Diagnostics.ProcessStartInfo
    $ProcessInfo.FileName = $FilePath
    $ProcessInfo.UseShellExecute = $false
    $ProcessInfo.Arguments = [string]$ArgumentList
    $Process = New-Object System.Diagnostics.Process
    $Process.StartInfo = $ProcessInfo
    $Process.Start() | Out-Null
    if(!$Process.WaitForExit($Timeout)){
        $Process.Kill()
        Write-Error ('Process timeout. Terminated. (Timeout:{0}s, Process:{1})' -f ($Timeout * 0.001), $FilePath)
    }
    $Process.ExitCode
}

Export-ModuleMember -Function *-TargetResource