Classes/BcServerInstance.ps1

# TODO Methods:
# - Create new server instance
# - Create report for installed extensions
# - Compare ServerInstance Configuration
# - Update ServerInstance to higher platform
# - Add switch 'enableEditing' to return a read only object by default.
# - GetLicenseFile
# - BC Application layer (installed apps report, mounted tenants)
# - feature rename BCS by deleting and recreating the BCS.

class BcServerInstance {
    [System.Object] $AppSettings
    hidden [System.Object] $_AppSettings
    hidden [string] $_SqlInstance 
    hidden [string] $_DatabaseServer 
    hidden [string] $_DatabaseInstance
    hidden [string] $_DatabaseName 
    hidden [string] $_StartMode
    hidden [string] $_ServiceName
    hidden [string] $_ComputerName
    hidden [string] $_ServerInstance
    hidden [pscredential] $_ServiceAccount
    hidden [System.Management.ManagementBaseObject] $_BcService
    hidden [string] $_State
    hidden [string] $_Version
    hidden [int] $_ProcessID
    hidden [bool] $_isLocalService
    hidden [bool] $_remotePsEnabled

    # Powershell 5.1 lacks a way to add get/set for properties.
    # Using Add-Member in method Init() is a workaround.
    # Following properties are set in the Init() method:

    # [string] $SqlInstance
    # [string] $DatabaseServer
    # [string] $DatabaseInstance
    # [string] $DatabaseName
    # [string] $StartMode
    # [string] $ServiceName # Property not editable
    # [string] $ComputerName # Property not editable
    # [string] $ServiceAccount # Property not editable, use SetServerInstance($usernamm, $passwordAsSecureString)
    # [string] $ServerInstance # Property not editable
    # [string] $State # Property not editable, use method Start(), Stop() or Restart()
    # [version] $Version # Property not editable
    # [int] $ProcessID # Property not editable

    BcServerInstance(
        [string] $WinBcServiceName,
        [string] $ComputerName
    ){       
        # WinBcServiceName can be either the full Windows service name or only the bc server instance name.
        # E.g. 'MicrosoftDynamicsNavServer$MyBcInstance' or 'MyBcInstance'
        if($WinBcServiceName -notlike 'MicrosoftDynamicsNavServer$*'){
            $WinBcServiceName = 'MicrosoftDynamicsNavServer${0}' -f $WinBcServiceName    
        }
        $this._ServiceName    = $WinBcServiceName
        $this._ComputerName   = $ComputerName
        $this.AppSettings     = New-Object BcAppSettings -ArgumentList @($this)

        # To support retreiving BC server instances from remote computers the ComputerName parameter should be set.
        # However, this property should not be set if the host and the target machine are the same and remote PS is not enabled.
        # Otherwise 'enable remote PowerShell' becomes a requirement to work on your local host.
        if ($this._ComputerName -eq 'localhost' -or $this._ComputerName -eq $env:COMPUTERNAME) {
            $this._isLocalService = $true
        } else {
            $this._isLocalService = $false
        }

        # If remote powershell is enabled on the localhost, use the computer parameter in invoke-command.
        # This will execute scriptblocks in a separate session, enabeling managing different BC platforms in one session.
        $this._remotePsEnabled = Test-RemotePowershell -ComputerName $this._ComputerName


        # In the Init the public properties are set.
        $this.Init()
        
        # Set the default visible properties of the object.
        # Additional parameters are available but hidden by default to keep the output clean.
        [string[]] $visible = 'ServerInstance', 'Version', 'State', 'ServiceAccount', 'SqlInstance', 'DatabaseName', 'ComputerName'
        [Management.Automation.PSMemberInfo[]] $psStandardMembers = New-Object `
            System.Management.Automation.PSPropertySet('DefaultDisplayPropertySet',$visible)
        $this | Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $psStandardMembers
    }

    ## START Initialisation and refresh methods
    hidden [void] Init(){
        # Add readonly property ServiceName.
        $this | Add-Member -Name 'ServiceName' -MemberType ScriptProperty -Value {
            return $this._ServiceName
        } -SecondValue {
            'Changing the Windows service name for the ServerInstance is not allowed.' | Write-Warning
            return
        }

        # Add readonly property ComputerName.
        $this | Add-Member -Name 'ComputerName' -MemberType ScriptProperty -Value {
            return $this._ComputerName
        } -SecondValue {
            'Changing the ComputerName for the ServerInstance is not allowed.' | Write-Warning
            return
        }
        $this.InitWinService()
        $this.InitAppSettings()
        
        # Create visible properties for each BC appsettings hidden key-value pair.
        foreach($key in $this._AppSettings.keys){
            
            $getter = [scriptblock]::Create("
                `$this._ServerInstance._AppSettings.$key"
)

            $setter = [scriptblock]::Create("
                Param([string] `$Value)
                `$this.SaveAppSetting('$Key', `$Value)
                `$this._ServerInstance.Refresh()"
)
            
            $this.AppSettings | Add-Member -Name $key -MemberType ScriptProperty -Value $getter -SecondValue $setter
        }
        
        # Add link between properties on root level and keys in BC appsettings.
        $this | Add-Member -Name 'SqlInstance' -MemberType ScriptProperty -Value {
            return $this._SqlInstance
        } -SecondValue {
            Param([string] $SqlInstance)
            if($SqlInstance -like '*\*'){
                $this.AppSettings.DatabaseServer   = $SqlInstance.Split('\')[0]
                $this.AppSettings.DatabaseInstance = $SqlInstance.Split('\')[1]
            } else {
                $this.AppSettings.DatabaseServer = $SqlInstance
            }
        }

        $this | Add-Member -Name 'DatabaseServer' -MemberType ScriptProperty -Value {
            return $this._DatabaseServer
        } -SecondValue {
            Param([string] $DatabaseServer)
            $this.AppSettings.DatabaseServer = $DatabaseServer
        }

        $this | Add-Member -Name 'DatabaseInstance' -MemberType ScriptProperty -Value {
            return $this._DatabaseInstance
        } -SecondValue {
            Param([string] $DatabaseInstance)
            $this.AppSettings.DatabaseInstance = $DatabaseInstance
        }

        $this | Add-Member -Name 'DatabaseName' -MemberType ScriptProperty -Value {
            return $this._DatabaseName
        } -SecondValue {
            Param([string] $DatabaseName)
            $this.AppSettings.DatabaseName = $DatabaseName
        }

        # Add readonly property ServerInstance.
        $this | Add-Member -Name 'ServerInstance' -MemberType ScriptProperty -Value {
            return $this._ServerInstance
        } -SecondValue {
            'Renaming the ServerInstance is not implemented.' | Write-Warning
            return
        }

        $this | Add-Member -Name 'Name' -MemberType ScriptProperty -Value {
            return $this._ServerInstance
        } -SecondValue {
            'Renaming the ServerInstance is not implemented.' | Write-Warning
            return
        }

        # Add readonly property ComputerName.
        $this | Add-Member -Name 'ProcessID' -MemberType ScriptProperty -Value {
            return $this._ProcessID 
        } -SecondValue {
            'Changing the Windows Service Process ID for the ServerInstance is not allowed.' | Write-Warning
            return
        }

        # Add readonly property Version.
        $this | Add-Member -Name 'Version' -MemberType ScriptProperty -Value {
            return [version] $this._Version
        } -SecondValue {
            'Changing the Business Central version for the ServerInstance is not allowed.' | Write-Warning
            return
        }

        # Add property ServiceAccount with custom getter and setter.
        $this | Add-Member -Name 'ServiceAccount' -MemberType ScriptProperty -Value { 
            return $this._ServiceAccount.UserName 
        } -SecondValue {   
            # ServiceAccount Credential can be set by updating the ServiceAccount property with a [pscredential] or
            # by calling the method SetServiceAccount($username, $PasswordAsSecureString).
            param([pscredential]$credential)
            $this.SetServiceAccount($credential)
            return
        }

        # Add property State with custom getter and setter.
        $this | Add-Member -Name 'State' -MemberType ScriptProperty -Value {   
            return $this._State
        } -SecondValue {   
            # State can be changed either by updating the State parameter or
            # by calling the method Start(), Stop() or Restart().
            param(
                [ValidateSet('start', 'running', 'stop', 'stopped', 'Restart')]
                [string] $State 
            )
                
            switch ($State) 
            {
                {$_ -eq 'Start' -or $_ -eq 'Running'}{ $this.Start()}
                {$_ -eq 'Stop'  -or $_ -eq 'Stopped'}{ $this.Stop()}
                {$_ -eq 'Restart'}{ $this.Restart()}
            }
            return
        }

        # Add property StartMode with custom getter and setter.
        $this | Add-Member -Name 'StartMode' -MemberType ScriptProperty -Value {   
            return $this._StartMode
        } -SecondValue {   
            param(
                [ValidateSet('auto', 'automatic', 'manual', 'disabled')]
                [string] $StartMode
            )
            $StartMode
            if($StartMode -eq 'Auto'){
                $StartMode = 'Automatic'
            }
            $this._BcService.ChangeStartMode($StartMode) 
            $this.InitWinService()
            return
        }
    }

    hidden [void] InitWinService(){
        $this._BcService       = $this.GetBcWinService()
        $this._ServerInstance  = $this._BcService.Name.Split('$')[1]
        $this._State           = $this._BcService.State
        $this._StartMode       = $this._BcService.StartMode
        $this._ComputerName    = $this._BcService.SystemName
        $this._ProcessID       = $this._BcService.ProcessID
        $this._Version         = $this.GetBcVersion()
        $this._ServiceAccount  = New-Object System.Management.Automation.PSCredential `
                                    ($this._BcService.StartName, (ConvertTo-SecureString 'dummypass' -AsPlainText -Force))
    }

    hidden [void] InitAppSettings(){    
        $this._AppSettings      = $this.AppSettings.GetBcAppSettings()
        $this._DatabaseServer   = $this._AppSettings.DatabaseServer
        $this._DatabaseInstance = $this._AppSettings.DatabaseInstance
        $this._DatabaseName     = $this._AppSettings.DatabaseName
        $this._SqlInstance      = $this.GetSqlInstance()
    }

    [void] Refresh(){
        $this.InitWinService()
        $this.InitAppSettings()
    }

    hidden [string] GetSqlInstance(){
        if ([string]::IsNullOrEmpty($this._AppSettings.DatabaseInstance)){
            $sqlInstanceName = $this._AppSettings.DatabaseServer
        } else {
            $sqlInstanceName = Join-Path $this._AppSettings.DatabaseServer $this._AppSettings.DatabaseInstance
        }
        return $sqlInstanceName
    }

    hidden [version] GetBcVersion(){
        $regex = '.*"(?<ServicePath>.*?.exe)".*'
        $match = $this._BcService.PathName | Select-String -Pattern $regex
        $executablePath = $match.Matches[0].Groups['ServicePath'].Value

        [scriptblock] $scriptBlock = {
            param(                
                [string] $executablePath
            )
            try{
                if((Test-Path $executablePath)){
                    [version] $executableVersion = (Get-Item $executablePath).VersionInfo.FileVersion
                } else {
                    Write-Warning ('The Business Central installation is not found for Server Instance ''{0}'' on location: {1}' -f 
                        ($this.Service.Name.Split('$'))[1], $executablePath)
                    return [version] '0.0.0.0'
                }
            } catch{
                return $_
            }
            return $executableVersion
        }

        $additionParams = @{}
        
        if(-not $this._isLocalService -or $this._remotePsEnabled -eq $true){
            $additionParams = @{'ComputerName' = $this._ComputerName}
        }
        $executableVersion = Invoke-Command @additionParams -ScriptBlock $scriptBlock `
                                -ArgumentList @($executablePath) -ErrorAction Stop

        return $executableVersion
    }

    [void] ImportBcPsModules(){
        $this.GetBcPsModules() | Import-Module -Scope Global -Force
    }

    [string[]] GetBcPsModules(){       
        [scriptblock] $scriptBlock = {
            param(                
                [string] $installationFolder
            )
    
            $modules = @(
                'Microsoft.Dynamics.Nav.Management'
                'Microsoft.Dynamics.Nav.Apps.Management'
                'Microsoft.Dynamics.Nav.Apps.Tools'
            )
    
            $modulePaths = @()
    
            # Check if the 'Management' sub-folder exists in the installation folder.
            $subFolder = Join-Path -Path $installationFolder -ChildPath "Management"
            if(Test-Path -Path $subFolder){
                $baseFolder = $subFolder
            } else {
                $baseFolder = $installationFolder
            }
    
            foreach($module in $modules){
                $modulePath = Join-Path -Path $baseFolder -ChildPath ($module + ".psd1")
    
                if (-not (Test-Path -Path $modulePath)) {
                    $modulePath = Join-Path -Path $baseFolder -ChildPath ($module + ".psm1")
                }
                if (-not (Test-Path -Path $modulePath)) {
                    
                    if($module -eq 'Microsoft.Dynamics.Nav.Management'){
                        'Module {0} not found on location {1}' -f $module, $modulePath | Write-Warning
                    }
                    continue
                }
                $modulePaths += $modulePath
            }
            return $modulePaths
        }

        $additionParams = @{}
        if(-not $this._isLocalService -or $this._remotePsEnabled -eq $true){
            $additionParams = @{'ComputerName' = $this._ComputerName}
        }
        $installationFolder = $this.GetInstallationFolder()
        $modulePaths = Invoke-Command @additionParams -ScriptBlock $scriptBlock `
                                -ArgumentList @($installationFolder) -ErrorAction Stop

        return $modulePaths
    }
    ## END Initialisation and refresh methods

    ## START Windows Service Management
    hidden [System.Management.ManagementBaseObject] GetBcWinService(){
        $service = [System.Management.ManagementBaseObject]
        $service = Get-WmiObject win32_service -ComputerName $this._ComputerName -ErrorAction Stop | Where-Object Name -eq $this._ServiceName
        
        if(-not $service){
            $message = 'Windows service {0} for Business Central Server Instance {1} not found.' -f 
                $this._ServiceName, $this._ServiceName.Split('$')[1]
            throw $message
        }

        return $service
    }

    [void] Start(){
        $this._BcService = $this.GetBcWinService()

        if ($this._BcService.State -eq 'Running'){
            'Serverinstance {0} is already running on computer {1}.' -f 
                $this.ServerInstance, $this.ComputerName | Write-Host
            return
        }
        if ($this._BcService.State -eq 'Pending'){
            'Serverinstance {0} is already starting on computer {1}.' -f 
                $this.ServerInstance, $this.ComputerName | Write-Host
            return
        }

        [scriptblock] $scriptBlock = {
            param(
                [string[]] $BcModulesPath,  
                [string] $ServerInstance
            )
            try{
                $BcModulesPath | Import-Module -Force
                Set-NAVServerInstance -ServerInstance $ServerInstance -Start -Confirm:$false

                # Start: Wait-BcServerInstanceMountingTenants
                # Waits for the the Service Instance to mount all attached tenants and executes Get-NavTenant in ForceSync mode
                $mounting = $false
                while($mounting -eq $false){
                    $tenants = Get-NAVTenant -ServerInstance $ServerInstance
                    if('Mounting' -notin $tenants.State){
                        $mounting = $true
                         
                    } else {
                        Start-Sleep -Seconds 10 
                    }   
                }
                # Invoke Get-NAVTenant with ForceRefresh to bring the tenants from state 'Mounted' to 'Operational'.
                (Get-NAVTenant -ServerInstance $ServerInstance).Id | ForEach-Object {
                    Get-NAVTenant -ServerInstance $ServerInstance -Tenant $_ -ForceRefresh
                }
                # End: Wait-BcServerInstanceMountingTenants
            }
            catch{return $_}
        }

        'Starting serverInstance {0} on computer {1}...' -f 
            $this.ServerInstance, $this.ComputerName | Write-Host
        
        $additionParams = @{}
        if(-not $this._isLocalService -or $this._remotePsEnabled -eq $true){
            $additionParams = @{'ComputerName' = $this._ComputerName}
        }
        Invoke-Command @additionParams -ScriptBlock $scriptBlock `
            -ArgumentList @($this.GetBcPsModules(), $this.ServerInstance) -ErrorAction Stop

        $this.InitWinService()
    }

    [void] Stop(){
        $this._BcService = $this.GetBcWinService()

        if ($this._BcService.State -eq 'Stopped'){
            'Serverinstance {0} is already stopped on computer {1}.' -f 
                $this.ServerInstance, $this.ComputerName | Write-Host
            return
        }

        [scriptblock] $scriptBlock = {
            param(
                [string[]] $BcModulesPath,  
                [string] $ServerInstance
            )
            try{
                $BcModulesPath | Import-Module -Force
                Set-NAVServerInstance -ServerInstance $ServerInstance -Stop
            }
            catch{return $_}
        }

        'Stopping serverInstance {0} on computer {1}...' -f 
            $this.ServerInstance, $this.ComputerName | Write-Host
        
        $additionParams = @{}
        if(-not $this._isLocalService -or $this._remotePsEnabled -eq $true){
            $additionParams = @{'ComputerName' = $this._ComputerName}
        }
        Invoke-Command @additionParams -ScriptBlock $scriptBlock `
            -ArgumentList @($this.GetBcPsModules(), $this.ServerInstance) -ErrorAction Stop

        $this.InitWinService()
    }

    [void] Restart(){
        $this.Stop()
        $this.Start()
    }

    [string] GetInstallationFolder(){
        $regex = '.*"(?<ServicePath>.*?.exe)".*'
        $match = $this._BcService.PathName | Select-String -Pattern $regex
        return Split-Path ($match.Matches[0].Groups['ServicePath'].Value) -Parent
    }

    [System.Object] GetComputerInfo(){
        $result = Get-FpsComputerInfo -Computer $this._ComputerName
        return $result
    }
    ## END Windows Service Management

    ## START Service Account management
    [void] SetServiceAccount( 
        [pscredential] $Credential
    ){
        [scriptblock] $scriptBlock = {
            param(
                [string[]] $BcModulesPath,
                [string] $ServerInstance,
                [string] $previousServiceAccount,
                [pscredential] $Credential                
            )
            try{
                $BcModulesPath | Import-Module -Force

                "Updating service account from '{0}' to '{1}' on ServerInstance {2}." -f
                    $previousServiceAccount, $Credential.UserName, $ServerInstance | Write-Host
                
                Set-NAVServerInstance -ServerInstance $ServerInstance `
                    -ServiceAccount User -ServiceAccountCredential $Credential
                
            } catch{
                return $_
            }
        }

        $additionParams = @{}
        if(-not $this._isLocalService -or $this._remotePsEnabled -eq $true){
            $additionParams = @{'ComputerName' = $this._ComputerName}
        }
        Invoke-Command @additionParams -ScriptBlock $scriptBlock `
            -ArgumentList @($this.GetBcPsModules(), $this._ServerInstance, $this._ServiceAccount.UserName, $Credential) -ErrorAction Stop
        
        $this.InitWinService()
    }

    [void] SetServiceAccount([string] $username, [securestring] $password
    ){
        [pscredential] $credential = New-Object System.Management.Automation.PSCredential ($userName, $password)
        $this.SetServiceAccount($credential)
    }

    [void] SetServiceAccount([string] $username,[string] $password){
        [securestring] $password = ConvertTo-SecureString $password -AsPlainText -Force
        [pscredential] $credential = New-Object System.Management.Automation.PSCredential ($userName, $password)
        'It is not recommended to store plain-text passwords in scripts.' | Write-Warning
        $this.SetServiceAccount($credential)
    }

    [void] SetServiceAccount(
        [string] $LocalAccount
    ){
        if($LocalAccount -notin @('LocalService', 'LocalSystem', 'NetworkService')){
            '{0} is not a valid option. Use one of the following options: LocalService, LocalSystem, NetworkService' -f 
                $LocalAccount | Write-Warning
            return
        }
        
        [scriptblock] $scriptBlock = {
            param(
                [string[]] $BcModulesPath,  
                [string] $ServerInstance,
                [string] $previousServiceAccount,
                [string] $LocalAccount
            )
            try{
                $BcModulesPath | Import-Module -Force

                "Updating service account from '{0}' to '{1}' on ServerInstance {2}." -f
                    $previousServiceAccount, $LocalAccount, $ServerInstance | Write-Host
                
                Set-NAVServerInstance -ServerInstance $ServerInstance -ServiceAccount $LocalAccount 
                
            } catch{
                return $_
            }
        }

        $additionParams = @{}
        if(-not $this._isLocalService -or $this._remotePsEnabled -eq $true){
            $additionParams = @{'ComputerName' = $this._ComputerName}
        }
        Invoke-Command @additionParams -ScriptBlock $scriptBlock -ErrorAction Stop -ArgumentList @(
            $this.GetBcPsModules(), 
            $this._ServerInstance, 
            $this._ServiceAccount.UserName, 
            $LocalAccount)
        
        $this.InitWinService()
    }
    ## END Service Account management
}