DSCClassResources/QlikPackage.psm1

enum Ensure {
  Absent
  Present
}

[DscResource()]
class QlikPackage {

    [DscProperty()]
    [String]$Name

    [DscProperty(Key)]
    [string]$Setup

    [DscProperty()]
    [string]$Patch

    [DscProperty(Mandatory)]
    [Ensure]$Ensure

    [DscProperty()]
    [string]$Log

    [DscProperty()]
    [Bool]$DesktopShortcut

    [DscProperty()]
    [Bool]$SkipStartServices

    [DscProperty()]
    [Bool]$SkipValidation

    [DscProperty()]
    [string]$InstallDir

    [DscProperty(Mandatory)]
    [PSCredential]$ServiceCredential

    [DscProperty()]
    [PSCredential]$DbSuperUserPassword

    [DscProperty()]
    [String]$Hostname

    [DscProperty()]
    [PSCredential]$DbCredential

    [DscProperty()]
    [string]$DbHost

    [DscProperty()]
    [ValidateRange(1, 65535)]
    [int]$DbPort

    [DscProperty()]
    [string]$RootDir

    [DscProperty()]
    [string]$StaticContentRootDir

    [DscProperty()]
    [string]$CustomDataRootDir

    [DscProperty()]
    [string]$ArchivedLogsDir

    [DscProperty()]
    [string]$AppsDir

    [DscProperty()]
    [bool]$CreateCluster

    [DscProperty()]
    [bool]$InstallLocalDb

    [DscProperty()]
    [bool]$ConfigureDbListener

    [DscProperty()]
    [string]$ListenAddresses

    [DscProperty()]
    [string]$IpRange

    [DscProperty()]
    [bool]$ConfigureLogging

    [DscProperty()]
    [bool]$SetupLocalLoggingDb

    [DscProperty()]
    [PSCredential]$QLogsWriterPassword

    [DscProperty()]
    [PSCredential]$QLogsReaderPassword

    [DscProperty()]
    [string]$QLogsHostname

    [DscProperty()]
    [ValidateRange(1, 65535)]
    [int]$QLogsPort

    [DscProperty()]
    [bool]$JoinCluster

    [DscProperty()]
    [Int]$ExitCode=0

    [DscProperty()]
    [bool]$AcceptEula

    [DscProperty()]
    [ValidateSet('Dashboard', 'Visualization')]
    [string[]]$BundleInstall

    [DscProperty()]
    [string] $SpcFilePath = "$env:temp\spc.cfg"

    [DscProperty(NotConfigurable)]
    [string] $ProductName

    [DscProperty(NotConfigurable)]
    [string] $PatchName

    [void] Set() {
        $currentState = $this.Get()
        if($this.Ensure -eq [Ensure]::Present) {
            Write-Debug "Setup: $($this.Setup)"
            $_productName = (Get-FileInfo $this.Setup).ProductName
            if ($currentState.ProductName -ne $_productName) {
                $installParams = @{
                    Path = $this.Setup
                    SkipStartServices = $this.SkipStartServices
                }
                $this_condensed = $this | Select-Object -Property $this.psobject.properties.Name.Where{ $this.$_ }
                if (! $currentState.ProductName) {
                    Write-Verbose "Installing $_productName"
                    $spc = $this_condensed | New-QlikSharedPersistenceConfiguration -Path $this.SpcFilePath
                    $installParams.SharedPersistenceConfig = $spc.FullName
                }
                else {
                    Write-Verbose "Upgrading from $($currentState.ProductName) to $_productName"
                }
                if ($this.Patch) {
                    Write-Verbose "Appending SkipStartServices to arguments as patch will be applied immediately after"
                    $installParams.SkipStartServices = $true
                }
                $this_condensed.psobject.properties.Name | ForEach-Object { Write-Debug "$_ : $($this_condensed.$_)"}
                $process = $this_condensed | Install-QlikPackage @installParams -ErrorAction Stop
            }
            else {
                Write-Verbose "Skipping install of $_productName as it is already installed"
            }

            if ($this.Patch) {
                $_productName = (Get-FileInfo $this.Patch).ProductName
                if ($currentState.PatchName -ne $_productName) {
                    Write-Verbose "Applying patch $_productName"
                    $process = Install-QlikPackage `
                        -Path $this.Patch `
                        -SkipStartServices:$this.SkipStartServices `
                        -Log $this.Log
                }
            }

            if (! $this.SkipStartServices) {
              Start-Service Qlik* -ErrorAction SilentlyContinue
            }
        } else {
            Write-Verbose "Uninstall $($this.Name)"
            [String]$parsedSetupParams = "-silent -uninstall"
            if($this.LogFile) { [String]$parsedSetupParams += " -log `"$($this.LogFile)`"" }
            Write-Verbose "Starting `"$($this.Setup)`" $parsedSetupParams"
            $startInfo = New-Object System.Diagnostics.ProcessStartInfo
            $startInfo.UseShellExecute = $false #Necessary for I/O redirection and just generally a good idea
            $process = New-Object System.Diagnostics.Process
            $process.StartInfo = $startInfo
            $startInfo.FileName = $this.Setup
            $startInfo.Arguments = $parsedSetupParams
            $process.Start() | Out-Null
            $process.WaitForExit()
            Write-Verbose "$($this.Name) uninstallation finished with Exitcode: $($process.ExitCode)"
        }
    }

    [bool] Test() {
        if($env:USERNAME -eq "$env:COMPUTERNAME$") {
            Write-Error "$($this.Name) can not be installed by 'LOCAL SYSTEM', please use PsDscRunAsCredential property"
        }
        $packages = $this.Get()
        if($this.Ensure -eq [Ensure]::Present) {
            $product = (Get-FileInfo $this.Setup).ProductName
            Write-Debug "Installed: $($packages.ProductName)"
            Write-Debug "Desired: $product"
            if($packages.ProductName -ne $product -and $packages.PatchName -ne $product) {
                Write-Verbose "Package $product not installed."
                return $false
            }
            else {
                Write-Verbose "Package $product already installed."
            }
            if ($this.Patch) {
                $update = (Get-FileInfo $this.Patch).ProductName
                if($packages.PatchName -ne $update) {
                    Write-Verbose "Patch $update not installed."
                    return $false
                }
                else {
                    Write-Verbose "Patch $update already installed."
                }
            }
        } else {
            if ($packages.ProductName) {
                Write-Verbose "$($this.ProductName) is installed but should be absent."
                return $false
            }
            if ($packages.PatchName) {
                Write-Verbose "$($this.PatchName) is installed but should be absent."
                return $false
            }
        }
        return $true
    }

    [QlikPackage] Get() {
        $package = [QlikPackage]::new()
        $products = Get-ItemProperty HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\* |
            Where-Object { $_.DisplayName -match "^Qlik Sense" }
        if($products) {
            Write-Debug "Found $($products.Count) packages"
            $package.ProductName = $products | Where-Object { $_.DisplayName -notmatch 'Patch' } | Select-Object -ExpandProperty DisplayName
            $package.PatchName = $products | Where-Object { $_.DisplayName -match 'Patch' } | Select-Object -ExpandProperty DisplayName
            $package.Ensure = [Ensure]::Present
        } else {
            Write-Debug 'No packages found'
            $package.Ensure = [Ensure]::Absent
        }
        return $package
    }

}

function Install-QlikPackage {
    [CmdletBinding(DefaultParameterSetName = 'Patch')]
    param (
        # Mandatory parameters
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateScript({Test-Path $_})]
        [System.IO.FileInfo]$Path,

        [Parameter(ParameterSetName = 'Install', Position = 1, ValueFromPipeline = $true)]
        [ValidateScript({Test-Path $_})]
        [Alias('spc')]
        [System.IO.FileInfo]$SharedPersistenceConfig,

        [Parameter(ParameterSetName = 'Install', Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [switch]$AcceptEula,

        [Parameter(ParameterSetName = 'Install', Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateScript({$_.UserName.IndexOf('\') -gt 0})]
        [pscredential]$ServiceCredential,

        [Parameter(ParameterSetName = 'Install', ValueFromPipelineByPropertyName = $true)]
        [ValidateScript({Test-QlikRepositoryPassword})]
        [Alias('DbSuperUserPassword')]
        [pscredential]$DbPassword,

        # Optional parameters
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [System.IO.FileInfo]$Log,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [switch]$DesktopShortcut,
        [Parameter()]
        [switch]$SkipStartServices,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [System.IO.DirectoryInfo]$InstallDir,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]$Hostname,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [switch]$SendData,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [switch]$SkipValidation,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateScript({Test-Path $_})]
        [System.IO.FileInfo]$DatabaseDumpFile,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateSet('Dashboard', 'Visualization')]
        [string[]]$BundleInstall,
        [Parameter()]
        [switch]$CleanUp
    )

    process {
        if ('Qlik_Sense_update.exe' -eq (Get-FileInfo $Path).OriginalFilename) {
            $Arguments = 'install'
            if (! $SkipStartServices.IsPresent) { $Arguments += ' startservices' }
            if ($Log) { $Arguments += " log=`"$Log`""}
        }
        else {
            $products = Get-ItemProperty HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\* |
                Where-Object { $_.DisplayName -match "^Qlik Sense" }
            $Arguments = '-silent'
            $Arguments += " userpassword=`"$($ServiceCredential.GetNetworkCredential().Password)`""
            if (! $products) {
                $Arguments += " userwithdomain=`"$($ServiceCredential.UserName)`""
                if ($SharedPersistenceConfig) { $Arguments += " sharedpersistenceconfig=`"$SharedPersistenceConfig`"" }
                if ($Hostname) { $Arguments += " hostname=`"$Hostname`"" }
                if ($SendData.IsPresent) { $Arguments += ' senddata=1' }
                if ($SkipValidation.IsPresent) { $Arguments += ' skipvalidation=1' }
                if ($DatabaseDumpFile) { $Arguments += " databasedumpfile=`"$DatabaseDumpFile`"" }
            }
            if ($AcceptEula.IsPresent) { $Arguments += ' accepteula=1' }
            if ($DbPassword) {
                $Arguments += (" dbpassword=`"{0}`"" -f ($DbPassword.GetNetworkCredential().Password))
            }
            if ($Log) { $Arguments += " -log `"$Log`""}
            if (!$DesktopShortcut.IsPresent) { $Arguments += ' desktopshortcut=0' }
            if ($SkipStartServices.IsPresent) { $Arguments += ' skipstartservices=1' }
            if ($InstallDir) { $Arguments += " installdir=`"$InstallDir`""}
            if ($BundleInstall) { $Arguments += " bundleinstall={0}" -f ($BundleInstall.ToLower() -join ',')}
            if ($CleanUp.IsPresent) { $Arguments += ' cleanup=1' }
        }

        $startInfo = New-Object System.Diagnostics.ProcessStartInfo
        $startInfo.UseShellExecute = $false
        $startInfo.FileName = $Path.FullName
        $startInfo.Arguments = $Arguments

        $process = New-Object System.Diagnostics.Process
        $process.StartInfo = $startInfo

        Write-Verbose "Starting `"$($startInfo.FileName)`" $($startInfo.Arguments -replace '(?<=password=")([^"]*)', '****')"
        $process.Start() | Out-Null
        $process.WaitForExit()
        if ($this.ExitCode -ne $process.ExitCode) {
            Write-Error "$($this.Name) installation failed with Exitcode: $($process.ExitCode)"
        }
        else {
            Write-Verbose "$($this.Name) installation finished with Exitcode: $($process.ExitCode)"
        }
        $process
    }
}

function New-QlikSharedPersistenceConfiguration {
    [CmdletBinding(DefaultParameterSetName = 'JoinCluster')]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [System.IO.FileInfo]$Path,

        # Database parameters
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateScript({Test-QlikRepositoryPassword})]
        [pscredential]$DbCredential,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]$DbHost,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateRange(1, 65535)]
        [int]$DbPort,

        # Logging
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [switch]$ConfigureLogging,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateScript({Test-QlikRepositoryPassword})]
        [pscredential]$QLogsWriterPassword,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateScript({Test-QlikRepositoryPassword})]
        [pscredential]$QLogsReaderPassword,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [string]$QLogsHostname,
        [Parameter(ValueFromPipelineByPropertyName = $true)]
        [ValidateRange(1, 65535)]
        [int]$QLogsPort,

        # Share paths
        [Parameter(ParameterSetName = 'CreateCluster', Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]$RootDir,
        [Parameter(ParameterSetName = 'CreateCluster', ValueFromPipelineByPropertyName = $true)]
        [string]$AppsDir = 'Apps',
        [Parameter(ParameterSetName = 'CreateCluster', ValueFromPipelineByPropertyName = $true)]
        [string]$StaticContentRootDir = 'StaticContent',
        [Parameter(ParameterSetName = 'CreateCluster', ValueFromPipelineByPropertyName = $true)]
        [string]$ArchivedLogsDir = 'Archived Logs',

        # Local DB
        [Parameter(ParameterSetName = 'CreateCluster', ValueFromPipelineByPropertyName = $true)]
        [switch]$InstallLocalDb,
        [Parameter(ParameterSetName = 'CreateCluster', ValueFromPipelineByPropertyName = $true)]
        [switch]$ConfigureDbListener,
        [Parameter(ParameterSetName = 'CreateCluster', ValueFromPipelineByPropertyName = $true)]
        [string[]]$ListenAddresses,
        [Parameter(ParameterSetName = 'CreateCluster', ValueFromPipelineByPropertyName = $true)]
        [string[]]$IpRange,
        [Parameter(ParameterSetName = 'CreateCluster', ValueFromPipelineByPropertyName = $true)]
        [int]$MaxConnections,
        [Parameter(ParameterSetName = 'CreateCluster', ValueFromPipelineByPropertyName = $true)]
        [switch]$SetupLocalLoggingDb
    )

    process {
        foreach ($item in @('AppsDir', 'StaticContentRootDir', 'ArchivedLogsDir')) {
            if ($PSCmdlet.ParameterSetName -eq 'CreateCluster') {
                $value = Get-Variable -Name $item -ValueOnly
                if (! [System.IO.Path]::IsPathRooted($value)) {
                    $value = $RootDir.TrimEnd('\') + "\$value"
                    Set-Variable $item $value
                    Write-Verbose "$item path not rooted, resolving to $value"
                }
            }
            else {
                Set-Variable $item $null
            }
        }

        $xmlWriter = New-Object System.XMl.XmlTextWriter($Path.FullName, $Null)
        $xmlWriter.Formatting = [System.Xml.Formatting]::Indented
        $xmlWriter.WriteStartDocument()
        $xmlWriter.WriteStartElement('SharedPersistenceConfiguration')
        $xmlWriter.WriteAttributeString('xmlns', 'xsi', 'http://www.w3.org/2000/xmlns/', 'http://www.w3.org/2001/XMLSchema-instance')
        $xmlWriter.WriteAttributeString('xmlns', 'xsd', 'http://www.w3.org/2000/xmlns/', 'http://www.w3.org/2001/XMLSchema')

        $xmlWriter.WriteElementString($PSCmdlet.ParameterSetName, 'true')

        $ParameterList = $PSCmdlet.MyInvocation.MyCommand.Parameters.Keys |
            Where-Object { $_ -ne 'Path' -and $_ -notin [System.Management.Automation.Cmdlet]::CommonParameters }

        foreach ($parameter in $ParameterList) {
            $value = Get-Variable -Name $parameter -ValueOnly -ErrorAction SilentlyContinue
            if (! $value) { continue }

            Write-Debug "$parameter : $value"
            switch ($value.GetType()) {
                'switch' {
                    $xmlWriter.WriteElementString($parameter, $value.ToString().ToLower())
                }
                'PSCredential' {
                    if ($parameter.SubString($parameter.Length - 10) -eq 'Credential') {
                        $name = $parameter.TrimEnd('Credential')
                        $xmlWriter.WriteElementString($name + 'UserName', $value.UserName)
                        $parameter = $name + 'UserPassword'
                    }
                    $xmlWriter.WriteElementString($parameter, $value.GetNetworkCredential().Password)
                }
                default {
                    $xmlWriter.WriteElementString($parameter, $value)
                }
            }
        }

        $xmlWriter.WriteEndElement()
        $xmlWriter.WriteEndDocument()
        $xmlWriter.Flush()
        $xmlWriter.Close()

        return Get-Item $Path
    }
}

function ConvertTo-PlainText {
    param (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [securestring]$SecureString
    )

    process {
        $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
        $plainpassword = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)

        return $plainpassword
    }
}

function Get-FileInfo($Path) {
    Write-Debug "Path: $Path"
    [System.Diagnostics.FileVersionInfo]::GetVersionInfo($Path)
}

function Test-QlikRepositoryPassword() {
    if ($_ -is [securestring]) {
        $password = $_ | ConvertTo-PlainText
    }
    else {
        $password = $_.GetNetworkCredential().Password
    }

    if ($password.Length -eq 0) {
        return $false
    }
    if ($password.Contains('"')) {
        return $false
    }
    if ($password.Contains("'")) {
        return $false
    }
    if ($password.Contains(';')) {
        return $false
    }
    return $true
}