Functions/AppManagement/Install-BcApp.ps1

<#
    .Synopsis
    Installs Microsoft Dynamics 365 Business Central AL Extension(s) on a Busines Central environment.
    .Description
    This script can install AL extension(s). It's compatible with single- and multi-tenant installations.
 
    The script is executed in two phases: Installation initialization and the installation itself.
    Initialization:
        - Creates a log file.
        - Starts the server instance if not started yet.
        - Sorts the to-install apps in the right installation order.
        - Displays details about the platform, BC environment and the to-install apps.
        - Validates environment preconditions.
        - Asks user confirmation to start installation.
    Installation:
        - Starts installation for each app in the right installation order.
        - Deploys the app in every tenant unless other specified using Install-BcSingleApp.
        - Restarts BC server tier.
    .Example
    Install-BcApp -ServerInstance 'BC150Test' -SoftwareToInstallPath (Join-Path $PSScriptRoot 'Extensions')
    .Example
    Install-BcApp -ServerInstance 'BC150Test' -SoftwareToInstallPath 'D:\Folder\Software'
    .Example
    Install-BcApp `
        -ServerInstance 'BC210Test' `
        -Tenant 'tenant1' `
        -SoftwareToInstallPath (Join-Path $PSScriptRoot 'Extensions') `
        -ErrorAction Continue `
        -SkipVerification
    # This will deploy the app files from the folder Extensions relative to the script root path.
    # The tenant is specified. This indicates the environment is Multi-Tenant AND the app(s) should ONLY be deployed in tenant1.
#>

function Install-BcApp {
    [CmdletBinding()]
    Param(
        # Specifies the name of a Business Central Server Instance. E.g. 'BC150' or 'NST150Test'
        [Parameter(Mandatory=$true)]
        [string] $ServerInstance,  

        # Path to the folder that contains the app file(s) to install.
        # The app in the database will either be installed or updated.
        [Alias('SoftwarePath')]
        [string] $SoftwareToInstallPath,

        # Path to the folder that contains the app file(s) to publish.
        # If the app is already installed in the database, the app will be updated.
        # Otherwise the app will be published only.
        [string] $SoftwareToPublishPath,

        # Path to the folder that contains the app file(s) to update only.
        # If the app is not yet installed in the database the app will be skipped.
        [string] $SoftwareToUpdatePath,

        # Tenant parameter can only be used with a Business Central multi tenant installation.
        # If no tenant is specified and the environment is multi-tenant the app will be published in all tenants.
        # E.g. 'tenant1' or @('tenant1', 'tenant2') to specify multiple tenants.
        [string[]] $Tenants,      

        # Use $AppScope to set the default scope for new installed apps.
        # If there is a previous version of an app installed the scope of this app will be used.
        # Global: The extension is published for all tenants on the server.
        # Tenant: The extension is published into the per-tenant scope.
        [ValidateSet('Global', 'Tenant')]
        [string] $AppScope,        

        # For Microsoft Partners with their own base app, supply the BaseAppId. Default value is the Microsoft Base App and 4PS Construct NL, W1, BE and UK.
        [string[]] $BaseAppId = @(  
            '437dbf0e-84ff-417a-965d-ed2bb9650972' # Microsoft Base App
            'd809cdc6-44fd-485c-b9ed-2841276bbf32' # 4PS Construct NL
            'a5fcb2f3-fce3-47a5-976a-e837599ae46d' # 4PS Construct W1
            'd179a7a3-819c-48fd-860d-5b9983836f6d' # 4PS Construct BE
            '3df31549-a7b5-4a12-9a01-8453e8819d4c' # 4PS Construct UK
        ),

        # Location to write the logfile to. E.g. 'C:\BcInstallation\Log'
        # Default location is '?:\ProgramData\4ps\bcdeployment'.
        [string] $LogFilePath = (Join-Path -Path $env:ProgramData -ChildPath '4ps\bcdeployment'),

        # When an extension is updated all dependend extensions will be uninstalled during the upgrade.
        # Default the dependend apps will be reïnstalled after the installation completed.
        # With $SkipAutoInstallChildApps on $true the dependend apps will not be reïnstalled.
        [switch] $SkipAutoInstallChildApps, 

        # For every 1 MB appfile (runtime) size roughtly 35 MB free memory is required for the serverinstance to compile the app.
        # For every 1 MB appfile (full) seze roughly 180 MB free memory is required.
        # This scripts validates if there is enough free memory to compile the largest to-install app.
        # This validation can be disabled by setting $SkipMemoryCheck to $true.
        [switch] $SkipMemoryCheck,

        # Default after the script initialisation the user is asked to continue or not.
        # This question can be disabled by setting $SkipConfirmation to $true.
        [switch] $SkipConfirmation,

        # Forces the deployment to run without verifying the authenticode signature.
        # Not recommendend in production environments.
        [switch] $SkipVerification,

        # Default the Business Central Service is restarted after installation.
        # To disable the service restart after the installation set $SkipServiceRestart to $true.
        [switch] $SkipServiceRestart,

        # Default the previous app will be unpublish after a succesfull installation.
        # To keep the previous app published in the database set $KeepPreviousApp to $true.
        [switch] $KeepPreviousApp,

        # DEPRECATED, use standaard PowerShell Gallery or import modules manually.
        [string] $ModulePath,

        # DEPRECATED by Set-BcLicense.ps1
        [string] $LicensePath,
        
        # DEPRECATED by Set-BcLicense.ps1
        [switch] $ForceLicense
    )
    "
     ____ _____ _____ _ _
    | _ \ / ____| | __ \ | | | |
    | |_) | | | | | | ___ _ __ | | ___ _ _ _ __ ___ ___ _ __ | |_
    | _ <| | | | | |/ _ \ '_ \| |/ _ \| | | | '_ `` _ \ / _ \ '_ \| __|
    | |_) | |____ | |__| | __/ |_) | | (_) | |_| | | | | | | __/ | | | |_
    |____/ \_____| |_____/ \___| .__/|_|\___/ \__, |_| |_| |_|\___|_| |_|\__|
                                | | __/ |
       4PS v{0} |_| |___/
    "
 -f $MyInvocation.MyCommand.Module.Version | Write-Host

    # During the pre-validation of the installation errors are considered blocking.
    $OriginalErrorActionPreference = $ErrorActionPreference
    $ErrorActionPreference = 'Stop'

    Test-Preconditions -Is64Bit -HasElevatedRights -ValidPowerShellVersion -HasFullLanguage -MinimalMajorVersion 5 -MinimalMinorVersion 0 -Verbose -ErrorAction Stop
    
    # Validate if module FpsGeneral is available on which BcDeployment depends
    if(-not (Get-Module FpsGeneral)){
        if(Get-Module -ListAvailable FpsGeneral){
            Import-Module FpsGeneral -DisableNameChecking -Force
        } else{
            Write-Error 'Please install and import PowerShell module FpsGeneral before executing this cmdlet.'
            return
        }
    }

    # Create workfolder directory as subdirectory in the LogFilePath directory.
    $workFolderName = '{0}_InstallBcApp' -f (Get-Date).ToString('yyyyMMdd_HHmmss')
    $workFolder = New-Item (Join-Path $LogFilePath $workFolderName) -ItemType Directory -Force

    # Start Logging
    try{ Stop-Transcript }catch{}
    $LogFile = New-FpsLogFile -LogFilePath $workFolder -LogFileNameSuffix $ServerInstance -LogFilesToPreserve 10
    Start-Transcript -path $LogFile -append

    # Write warning when depricated parameters are used.
    if($LicensePath){
        $msg = 'Parameters LicensePath and ForceLicense are obsolete. '
        $msg += 'Use Set-BcLicense to import the BC license.'
        Write-Warning $msg
    }
    if($ModulePath){
        Write-Warning 'Parameter ModulePath is obsolute.'
    }

    # Get the BC ServerInstance object
    $ServerInstanceObj = Get-BcServerInstance -ServerInstance $ServerInstance
    if(-not $ServerInstanceObj){
        throw ('Could not find the Business Central ServerInstance {0}.' -f $ServerInstance)
    }    

    # Import Business Central Modules
    $ServerInstanceObj.ImportBcPsModules()
    $TotalTime = Write-StartTask 'Microsoft Dynamics 365 Business Central AL Extension(s) deployment on a Busines Central environment.'

    # Start BC ServerInstance if not running
    $TaskStartTime = Write-StartTask 'Validating Business Central server instance state.' 
    if($ServerInstanceObj.State -ne 'Running'){
        'ServerInstance {0} is not running. Starting service...' -f $ServerInstance | Write-Host
        $ServerInstanceObj.Start()
    } else {
        'ServerInstance {0} is running.' -f $ServerInstance | Write-Host
    }

    # Set the tenant parameter if not set
    if(-not $Tenants){
        $Tenants = (Get-NAVTenant -ServerInstance $ServerInstance).Id
    }
    Write-EndTask $TaskStartTime

    ### Get installation details
    $TaskStartTime = Write-StartTask 'Retrieving installation details.' 

    # Get and save the original state of the apps to a json file in the workfolder.
    $originalStateJsonPath = Join-Path $workFolder 'originalState.json'
    $originalState = Get-BcTenant -ServerInstance $serverInstance -Tenant $Tenants -ErrorAction Stop
    $originalState | ConvertTo-Json -Depth 10 | Set-Content -Path $originalStateJsonPath

    # Set the desired state and scope for the to install apps
    'Setting the desired state and scope for the supplied extensions...' | Write-Host

    $softwarePaths = @()
    if($SoftwareToInstallPath){
        $softwarePaths += $SoftwareToInstallPath
        $appsToInstall = Get-ChildItem -Path $SoftwareToInstallPath -Recurse -Filter "*.app"
    }
    if($SoftwareToPublishPath){
        $softwarePaths += $SoftwareToPublishPath
        $appsToPublish = Get-ChildItem -Path $SoftwareToPublishPath -Recurse -Filter "*.app"
    }
    if($SoftwareToUpdatePath){
        $softwarePaths += $SoftwareToUpdatePath
    }
    $apps = Get-AppDependencyOrder -Path $softwarePaths

    # Add the DesiredState property to app.
    foreach($app in $apps){
        $app | Add-Member NoteProperty -Name DesiredState -Value ''
        
        if($app.Path -in $appsToInstall.FullName){
            $app.DesiredState = 'Installed'
            continue
        }

        if($app.Path -in $appsToPublish.FullName){
            $app.DesiredState = 'Published'
            continue
        }

        $app.DesiredState = 'Skipped'
    }

    # Add the DesiredScope property to app.
    foreach($app in $apps){
        $app | Add-Member NoteProperty -Name DesiredScope -Value ''

        if($AppScope){
            $app.DesiredScope = $AppScope
            continue
        } 

        $app.DesiredScope = 'Global'
    }

    # Change desired state based on apps found in the db.
    foreach($app in $apps){
        
        $appInDb = $originalState.Extensions | Where-Object {
            $_.AppId.Value.Guid -eq $app.AppId.Value.Guid -and
            $_.IsInstalled -eq $true
        }
    
        if(-not $appInDb){ 
            continue 
        }

        # The desired state is overrulled by the installed app in the db.
        if($app.DesiredState -in @('Published', 'Skipped')){
            
            # ' Updated DesiredState for app {0} from Published to Installed.' -f
            # $app.Name | Write-Host

            $app.DesiredState = 'Installed'
        }

        # If the AppScope is not specified:
        # inherit the scope from the currently installed app
        if(-not $AppScope -and $app.Scope -ne $appInDb.Scope){

            ' Updated DesiredScope for app {0} from {1} to {2}.' -f
                $app.Name, $app.Name, $appInDb.Scope | Write-Host

            $app.DesiredScope = $appInDb.Scope
        }
    }

    <# TODO
        Add manifest file support here.
         - copy manifest file to the work folder
         - apply the app desired state and scope to the to-install apps.
         * the job.json can be used as manifest file.
    #>


    # Save the app files with desired state to job.json.
    # This represents the change the installation script is going to make.
    $jobJsonPath = Join-Path $workFolder 'job.json'
    $apps | ConvertTo-Json -Depth 10 | Set-Content -Path $jobJsonPath

    <# TODO
        Create a desiredState.json that can later be compared with the installation result.
        The desiredState is a combination of the to-install apps and the already installed
          apps in the database.
        The desiredState.json can also be used to re-install childapps that are not yet
          reinstalled by Install-BcSingleApp.ps1
    #>

    <# TODO
        Implement Write-BcInstallationReport.
        This should show the expected change from the original state to the
         desired state.
        Function can also be used to display issues by comparing
         the desiredState with the installation result.
    #>


    # Write summary to host
    Write-Title 'Installation Summary' -SpacerCharacter '='
    
    $computerInfo = Get-FpsComputerInfo
    Write-Title 'Platform' -SingleLine
    Write-Host ($ComputerInfo | Out-String)

    Write-Title 'Business Central Server Instances' -SingleLine
    Write-Host (Get-BCServerInstance -ServerInstance $ServerInstance | Out-String)

    Write-Title 'Apps to deploy' -SingleLine
    Write-Host ($Apps | Sort-Object -Property Name |
        Select-Object -Property Name, Publisher, Version, DesiredState, DesiredScope | 
        Format-Table | Out-String)

    Write-Title 'Apps in database' -SingleLine
    Write-Host ($originalState.Extensions | 
        Select-Object -Property Name, Publisher, Version, IsPublished, IsInstalled, Scope | 
        Format-Table | Out-String)

    # TODO Create view for apps to deploy and current apps in one.
    # updating from.. to..

    Write-Title 'Targeted Tenants' -SingleLine
    Write-Host (Get-NAVTenant -ServerInstance $ServerInstance | `
            Select-Object -Property Id, State, AllowAppDatabaseWrite, DatabaseName, DatabaseServer, TenantDataVersion | `
            Where-Object -Property Id -in $Tenants | Out-String)

    Write-Title 'Bound Parameters' -SingleLine
    Write-Host ($PSCmdlet.MyInvocation.BoundParameters | Out-String)

    Write-Title ' End installation summary ' -SingleLine -SpacerCharacter '='
    Write-EndTask $TaskStartTime

    ### Validations on preconditions before installation
    $TaskStartTime = Write-StartTask "Validating environment." 
    # Validate if enough free memory is available.
    # Publishing large extensions in Business Central consumes a lot of memory, because the whole application is compiled on publishing.
    if(-not $SkipMemoryCheck){
        # Get the filesize of the largest app file
        $FileSize = ($Apps | Sort-Object -Property Length -Descending | Select-Object -First 1).Length
        
        # Calculate minimal free memory in GB. Every 1 MB appfile equals roughly 180 MB free memory, 1 MB equals ~35 MB for runtime apps.
        $MinimalFreeMemory = [math]::round($FileSize /1MB, 2) * 35
        $MinimalFreeMemory = [math]::round($MinimalFreeMemory * 1MB /1GB, 1)

        if([decimal] $ComputerInfo.TotalFreeMemoryGB -lt [decimal] $MinimalFreeMemory){
            $Message  = 'Not enough free memory available. System has {0} GB of the {1} GB memory available. ' -f 
                            $ComputerInfo.TotalFreeMemoryGB, $ComputerInfo.TotalMemoryGB
            $Message += 'Atleast {0} GB free memory is required. You can disable this validation by setting the -SkipMemoryCheck switch.' -f $MinimalFreeMemory
            throw $Message
        }

        '{0} GB free memory is above the minimal threshold of {1} GB.' -f
            $ComputerInfo.TotalFreeMemory, $MinimalFreeMemory | Write-Host
    }

    # Validate if tenants are valid
    $MountedTenants = Get-NAVTenant -ServerInstance $ServerInstance
    $Tenants | ForEach-Object {

        if($_ -notin $MountedTenants.Id){
            $Message = "Supplied tenant '{0}' is not mounted on the ServerInstance '{1}'." -f 
                            $_, $ServerInstance
            throw $Message
        } 
        $TenantState = ($MountedTenants | Where-Object -property Id -eq $_).State
        
        if($TenantState -eq 'OperationalDataUpgradePending'){
            Invoke-BcDataUpgrade -ServerInstance $ServerInstance -Tenant $_
            
            $MountedTenants = Get-NAVTenant -ServerInstance $ServerInstance
            $TenantState = ($MountedTenants | Where-Object -property Id -eq $_).State
        }
        
        if($TenantState -ne 'Operational'){
            $Message  = "The tenant '{0}' doesn't have the status 'Operational'. The tenant status is {1}.`n" -f 
                            $_, $TenantState
            $Message += "Please make the tenant operational before starting the deployment."
            throw $Message
        }
    }
    'Supplied tenant(s) are mountend and operational on ServerInstance {0}' -f $ServerInstance | Write-Host
    Write-EndTask $TaskStartTime

    $Message  = 'This script does NOT make a SQL backup from the Business Central database. ' 
    $Message += 'Please make sure you have a valid backup before continuing.' 
    Write-Warning -Message $Message

    # TODO, Replace with -Confirm param to use PS standaard. See Set-BcLicense as example.
    # Note: Supporting the What-If might be more difficult, but is enabled at the same time.
    if(-not $SkipConfirmation){
        
        'All validations have passed. Do you want to continue the installation?' | 
            Write-Host -ForegroundColor Green
        
        Show-MessageBox -MessageType InShell -Button OKCancel -Throw `
                        -Message 'Installation Details' `
                        -ErrorMsg 'User canceled installation.'
    }

    $ErrorActionPreference = $OriginalErrorActionPreference

    "
         _____ _ _ _ _ _
        |_ _| | | | | | | | (_)
          | | _ __ ___| |_ __ _| | | __ _| |_ _ ___ _ __
          | | | '_ \/ __| __/ _`` | | |/ _`` | __| |/ _ \| '_ \
         _| |_| | | \__ \ || (_| | | | (_| | |_| | (_) | | | |
        |_____|_| |_|___/\__\__,_|_|_|\__,_|\__|_|\___/|_| |_|
                            Starts here
    "
 | Write-Host

    ### Execute installation script
    $TaskStartTime = Write-StartTask "Installing Business Central Extensions." 

    # Loop through all the app files and install them.
    # Appfiles are already sorted on right installation order.
    foreach ($App in $Apps) {

        if($App.DesiredState -eq 'Skipped'){
            continue
        }
        
        $IsBaseApp = $false
        if($App.AppId.Value.Guid -in $BaseAppId){        
            $IsBaseApp = $true
        } 

        $PublishOnly = $false
        if($App.DesiredState -eq 'Published'){
            $PublishOnly = $true
        }

        foreach($Tenant in $Tenants){

            $installAppArgs = @{
                'ServerInstance'   = $ServerInstance
                'AppFilePath'      = $App.Path
                'Tenant'           = $Tenant
                'AppScope'         = $App.Scope
                'PublishOnly'      = $PublishOnly
                'IsBaseApp'        = $IsBaseApp
                'KeepPreviousApp'  = $KeepPreviousApp
                'SkipVerification' = $SkipVerification
            }
            Install-BcSingleApp @installAppArgs
        } # End foreach tenant
    } # End foreach app file

    # Install child-apps again that are deïnstalled during the installation.
    'Reinstalling child-apps that were uninstalled during the installation...' | Write-Host
    $currentState = Get-BcTenant -ServerInstance $serverInstance -Tenant $Tenants
    
    foreach($currentStateTenant in $currentState){
    
        $originalTenantState = $originalState | Where-Object{$_.Id -eq $currentStateTenant.Id}
        
        $originalInstalledApps = $originalTenantState.Extensions | Where-Object {
            
            # Exclude apps that are part of the installation.
            # These apps should be installed through Install-BcSingleApp
            $_.AppId.Value.Guid -notin $apps.AppId.Value.Guid -and
    
            # Include only apps that were installed at the start of the installation.
            $_.IsInstalled -eq $true
        }
    
        $appsToInstall = @()
        foreach($originalInstalledApp in $originalInstalledApps){
            
            # Match the original app with the currently published apps.
            $currentApps = $currentStateTenant.Extensions | Where-Object {
                $originalInstalledApp.AppId.Value.Guid -eq $_.AppId.Value.Guid
            }
    
            # If there is an installed version for the child-app, continue.
            if($true -in $currentApps.IsInstalled){
                continue
            }
    
            $appsToInstall += $originalInstalledApp
    
        } # end foreach $originalInstalledApps
    
        if(($appsToInstall | Measure-Object).Count -gt 1){
            $appsToInstall = Get-AppDependencyOrder -Apps $appsToInstall -ServerInstance $ServerInstance
        }
    
        foreach($appToInstall in $appsToInstall){
            ' Installing child-app {0} version {1} for tenant {2}.' -f 
                $appToInstall.Name, $appToInstall.Version, $currentStateTenant.Id | 
                Write-Host
    
            Install-NAVApp `
                -ServerInstance $ServerInstance `
                -Name $appToInstall.Name `
                -Version $appToInstall.Version `
                -Tenant $currentStateTenant.Id `
                -Force
        }
    
    } # end foreach tenant in $currentState

    'Total deploy time Business Central Extension(s):' | Write-Host
    Write-EndTask $TaskStartTime


    $TaskStartTime = Write-StartTask ('Restarting ServerInstance {0}.' -f $ServerInstance)
    if($SkipServiceRestart -eq $false){
        Set-NavServerInstance `
            -ServerInstance $ServerInstance `
            -Restart
        Wait-BcServerInstanceMountingTenants `
            -ServerInstance $ServerInstance
    } else {
        'Skipped restarting ServiceInstance because switch SkipServiceRestart is set.'
    }
    Write-EndTask $TaskStartTime


    $TaskStartTime = Write-StartTask ('Writing summary.')

    $Tenants | ForEach-Object {
        Write-Title ('Extension states for tenant {0}' -f $_)
        $Message = (Get-NavAppInfo `
                        -ServerInstance $ServerInstance `
                        -Tenant $_ `
                        -TenantSpecificProperties | Sort-Object -Property Name, Version | `
                            Select-Object -Property Name, Publisher, IsPublished, SyncState, NeedsUpgrade, IsInstalled, Version, Scope, ExtensionDataVersion, AppId | `
                            Format-Table | Out-String)
        Write-host $Message
        Write-Title '' -SingleLine '_'
    }

    # Write warnings for apps that did not reach the expected state.
    foreach($tenant in $Tenants){
        $currentTenantApps = Get-NavAppInfo `
                                -ServerInstance $ServerInstance `
                                -Tenant $tenant `
                                -TenantSpecificProperties
        $currentTenantApps | ForEach-Object {
            $_ | Add-Member -MemberType NoteProperty -Name 'tenantId' -Value $tenant
        }
        
        $success = $true
        foreach($app in $apps){
            # Ignore apps marked as skipped (update only apps)
            if($app.DesiredState -eq 'Skipped'){
                continue
            }

            # Match the to-install app with the app in the database.
            $currentApp = $currentTenantApps | Where-Object {
                $app.AppId.Value.Guid -eq $_.AppId.Value.Guid -and
                $app.Version -eq $_.Version
            }

            # Continue if the desiredState matches the state in the database.
            $toInstall = $app.DesiredState -eq 'Installed'
            if($toInstall -eq $currentApp.IsInstalled){
                continue
            }
            $toPublish = $app.DesiredState -eq 'Published'
            if($toPublish -eq $currentApp.IsPublished){
                continue
            }

             # The desired state is not matched, write a warning.
            $success = $false

            $msg  = "The BC extension '{0}' with version '{1}' does not have the expected state '{2}' in tenant {3}. "
            if(-not $currentApp){
                $msg += 'The extension is not found in the database. '
            } 
            $msg = $msg -f $app.Name, $app.Version.ToString(), $app.DesiredState, $tenant

            Write-Warning $msg
        }

        $msg += 'Please investigate the logging and verify the reason why the extension did not reach the expected state.'
    }    
    Write-EndTask $TaskStartTime

    'Total execution time:' | Write-Host
    Write-EndTask $TotalTime

    if(-not $success){
        $msg  = 'Deployment completed with errors. '
        $msg += 'Please investigate the logging and verify why the extension(s) did not reach the expected state.'
        Write-Warning $msg
    } else {
        'Deployment completed.' | Write-Host -ForegroundColor Green
    }

    try{ Stop-Transcript }catch{}
}

Export-ModuleMember -Function Install-BcApp

<#
TODO:
    originalState.json -> the current state of the database before installation
    job.json -> the list of to publish and install apps + Scope and State from originalState + Manifest.json
    desiredState.json -> the sum of job and originalState == Manifest
    result.json -> the resulted state.
 
    job.json + originalState.json + manifest.json = desiredState.json - result.json = unexpected results.
#>