Functions/AppManagement/Install-BcSingleApp.ps1

<#
    .Synopsis
    Installs a single Microsoft Dynamics 365 Business Central AL Extension on a Busines Central environment.
    .Description
    This script can install an AL extension
 
    Installation:
        o Publish-NavApp
        o Determine the next installation procedure
        o Uninstall-NavApp (previous version)
        o Sync-NavApp
        o Start-NavAppDataUpgrade or Install-NavApp
        o Set application version in database (if the app is a base app).
        o Unpublish-NavApp (previous app)
        o Restarts BC server tier.
    .Example
    Install-BcSingleApp -ServerInstance 'BC210Test' AppFilePath 'c:\MyExtension.app' -tenant 'default'
    .Example
    $installAppArgs = @{
        'ServerInstance' = $ServerInstance
        'AppFilePath' = 'c:\MyExtension.app'
        'Tenant' = 'default'
        'AppScope' = 'Global'
        'PublishOnly' = $true
        'SkipVerification' = $SkipVerification
    }
    Install-BcSingleApp @installAppArgs
#>


function Install-BcSingleApp {
    param(
        [string] $ServerInstance,
        [string] $AppFilePath,
        [string] $Tenant,
        
        [ValidateSet('Global', 'Tenant')]
        [string] $AppScope,     
        
        [switch] $PublishOnly,
        [switch] $IsBaseApp,
        [switch] $KeepPreviousApp,
        [switch] $SkipVerification
    )

    $newApp = Get-NAVAppInfo -Path $AppFilePath
    $newApp | Add-Member -NotePropertyName 'Guid' `
                      -NotePropertyValue $newApp.AppId.Value.Guid
    
    Write-Title ('Installation: {0} v{1}' -f 
        $newApp.Name, $newApp.Version.Tostring())

    # Start tracking installation time.
    $appDeployStartTime = "Installation started on tenant {0}" -f
                            $Tenant | Write-StartTask
    
    'Location app file: {0}' -f $AppFilePath | Write-Host
    
    # Set variables for the installation.
    $skipPublish     = $false
    $skipUnInstall   = $false
    $skipSync        = $false
    $skipDataUpgrade = $false
    $skipInstall     = $false
    $skipUnpublish   = $false

    # Set hashtable installReport to keep track off the duration of
    # the installation steps and to write a report after the app installation.
    $report = @{
        Publish     = @{ Title = 'Publishing new app'}
        UnInstall   = @{ Title = 'Uninstalling previous app' }
        TableSync   = @{ Title = 'Table Synchronisation new app' }
        DataUpgrade = @{ Title = 'Dataupgrade and installation new app'}
        Install     = @{ Title = 'Installation new app'}
        Unpublish   = @{ Title = 'Unpublishing previous app'}
    }

    # Set forground color for Write-Host
    $c = @{
        'ForegroundColor' = 'Cyan'
    }

    # Check for previous version of the app.
    'Scan for earlier published apps for app {0} in tenant {1}.' -f 
        $newApp.Name, $Tenant | Write-Host @c

    $currentApp = Get-NAVAppInfo `
            -ServerInstance $ServerInstance `
            -Id $newApp.Guid `
            -Tenant $Tenant `
            -TenantSpecificProperties
    
    $currentApp | Add-Member -NotePropertyName 'Guid' `
                             -NotePropertyValue $currentApp.AppId.Value.Guid

    # Validate if supplied app file contains a newer app than the current published apps.
    foreach($app in $currentApp){
        
        if ($app.Version -gt $newApp.Version){
            $msg = "The supplied app with version {0} is lower "
            $msg += "than the already deployed app with version {1}.`n "
            $msg += 'Skipping installation for app {2}.'
            $msg = $msg -f $app.Version.Tostring(), 
                           $newApp.Version.Tostring(),
                           $app.Name
            
            if($IsBaseApp){
                throw $msg
            }

            Write-Warning $msg
            return
        }
    }

    # If multiple versions of the same app are published, select one as current app.
    if($currentApp -is [array] -and $currentApp.Count -gt 1){
        'Multiple ({0}) versions of app {1} are already published.' -f 
            $currentApp.Count, $newApp.Name | Write-Host
        
        $installedApp = $currentApp | Where-Object -Property IsInstalled -eq $true
        $dataVersionMatch = $currentApp | Where-Object Version -eq $currentApp[0].ExtensionDataVersion
        
        # Select the app that is currently installed
        if($installedApp){
            $currentApp = $installedApp
        } 
        # Select the version that correspond with the current tenant data version as current app.
        elseif($dataVersionMatch){
            $currentApp = $dataVersionMatch
        } 
        # If neither are present, select the oldest version of the app
        else{
            $currentApp = ($currentApp | Sort-Object -Property Version)[0]
        }
    }
    
    if(-not $currentApp){
        $msg = 'SkipUnpublish is set to true because there are no other '
        $msg + 'published versions found for app {0}.' -f 
                    $newApp.Name | Write-Host
        
        $skipUnpublish = $true
    }
    else {
        # Write current app details to host.
        'Previous version of the app found: {0} - {1}' -f
            $currentApp.Name, $currentApp.Version.ToString() | Write-Host

        # Skip publish if the supplied app is already published.
        if ($newApp.Version -eq $currentApp.Version){
            $msg = 'SkipPublish and SkipUnpublish are set to true because the version '
            $msg + '{0} of the supplied app is equal to the app in the database.' -f 
                        $newApp.Version.ToString() | Write-Host

            $skipPublish   = $true
            $skipUnpublish = $true
        }
    } 

    if($KeepPreviousApp){
        $msg = 'SkipUnpublish is set to true, because the switch KeepPreviousApp '
        $msg + 'is enabled.' | Write-Host
        
        $skipUnpublish = $true
    }

    # Get app scope if not set.
    if ($currentApp) {
        $AppScope = $currentApp.Scope
        'The extension scope is set to the same level as the previous app: {0}' -f 
            $currentApp.Scope | Write-Host
    } elseif (-not $AppScope) {
        'The extension scope is set to the default value: Tenant' | Write-Host
        $AppScope = 'Tenant'
    } else {
        'Appscope is set as installation parameter: {0}' -f $AppScope | Write-Host
    }
    ### END *** App file installation initialization ***
    
    ### START *** App file installation ***

    #-------------------------------Publish App---------------------------------
    $null = Write-StartTask 'Publishing extension.'
    $report.Publish['StartTime'] = Get-Date
    
    if($skipPublish){
        'Skipped Publish-NAVApp.' | Write-Host
    } else {
        'Publishing {0} App, Guid: {1}, version {2} on server instance {3}.' -f 
            $newApp.Name, $newApp.Guid, $newApp.Version.ToString(), $ServerInstance | 
            Write-Host
        
        $message = "Publish-NAVApp -ServerInstance '{0}' -Path '{1}' -Scope '{2}' -SkipVerification:{3}" -f 
                        $ServerInstance, $AppFilePath, $AppScope, $skipVerification
                                
        $arguments = @{
            'ServerInstance'   = $ServerInstance
            'Path'             = $AppFilePath
            'Scope'            = $AppScope
            'SkipVerification' = $skipVerification
        }

        if($AppScope -eq 'Tenant'){
            $arguments += @{
                'Tenant' = $Tenant
            }
            $message += " -Tenant '{0}'" -f $Tenant
        }

        $message | Write-Host -ForegroundColor Gray
        Publish-NavApp @arguments   
    } 
    $report.Publish['Duration'] = (Get-Date) - $report.Publish.StartTime


    #--------------------------Calculate next steps-----------------------------
    $null = Write-StartTask 'Determine which next installation steps are required.'
    
    # Update $newApp with tenant specific installation status.
    $newApp = Get-NAVAppInfo `
                -ServerInstance $ServerInstance `
                -Id $newApp.Guid `
                -Version $newApp.Version `
                -Tenant $Tenant `
                -TenantSpecificProperties

    # Check if Sync-NAVApp should be executed.
    if($newApp.SyncState -eq 'Synced'){
        'SkipSync is set to true, table definition is already in sync with SQL tables.' | Write-Host @c
        $skipSync = $true
    } else {
        'Sync-NAVApp should be executed. Syncstate is {0}' -f $newApp.SyncState | Write-host @c
    }
    
    # Check if Start-NAVAppDataUpgrade should be executed.
    if($newApp.ExtensionDataVersion -eq $newApp.Version){
        'SkipDataUpgrade is set to true, extensions data already upgraded.' | Write-Host @c
        $skipDataUpgrade = $true
    } 
    elseif([string]::IsNullOrEmpty($newApp.ExtensionDataVersion)){
        'SkipDataUpgrade is set to true, extensionDataVersion is empty.' | Write-Host @c
        ' It is the first time this extension is installed.' | Write-Host @c
        $skipDataUpgrade = $true
    }
    else {
        'Start-NAVAppDataUpgrade should be executed.' | Write-Host @c
        ' Extension dataversion in tenant {0} is {1} and should be upgraded to {2}.' -f 
                $Tenant,
                $newApp.ExtensionDataVersion.ToString(), 
                $newApp.Version.ToString() | Write-host @c
    }
    
    # Check if there is a previous app that should be uninstalled.
    if([string]::IsNullOrEmpty($currentApp)){
        'SkipUnInstall is set to true, no previous extension found.' | Write-Host @c
        $skipUnInstall = $true
    } 
    elseif ($currentApp.IsInstalled -eq $false) {
        'SkipUnInstall is set to true, previous extension is not installed.' | Write-Host @c
        $skipUnInstall = $true
    } 
    elseif($currentApp.IsInstalled -eq $true -and 
                $skipSync -eq $true -and 
                $skipDataUpgrade -eq $true){
        'SkipUnInstall is set to true, there is no table sync or dataupgrade planned for the extension.' | Write-Host @c
        $skipUnInstall = $true
    } 
    else {
        'Previous app will be uninstalled due to a pending table sync or dataupgrade.' | Write-Host @c
    }

    # Check if Install-NavApp should be executed.
    # Only use Install-NavApp if the new app has not been installed before or if the app is already in sync and upgraded.
    if($newApp.IsInstalled -eq $true){
        'SkipInstall is set to true, extension is already installed.' | Write-Host @c
        $skipInstall = $true
    }
    elseif($skipDataUpgrade -eq $false){
        'SkipInstall is set to true, Extension will be installed during the dataupgrade.' | Write-Host @c
        $skipInstall = $true
    } 

    # When publish only is enabled the sync, dataupgrade and installation should be skipped.
    if($PublishOnly){
        'skipSync, skipDataUpgrade and skipInstall are set to false because PublishOnly is enabled.' | Write-Host @c
        $skipSync        = $true
        $skipDataUpgrade = $true
        $skipInstall     = $true
    }

    # Write next steps to host
    $msg  = @()
    $msg += ' skipUnInstall = {0}' -f $skipUnInstall
    $msg += ' skipSync = {0}' -f $skipSync
    $msg += ' skipDataUpgrade = {0}' -f $skipDataUpgrade
    $msg += ' skipInstall = {0}' -f $skipInstall
    $msg += ' skipUnpublish = {0}' -f $skipUnpublish
    $msg | Write-Host

    #------------------------Uninstalling previous app--------------------------

    $null = Write-StartTask 'Uninstalling previous app.'
    $report.Uninstall['StartTime'] = Get-Date

    # Uninstall current app if applicable. Uninstall app is only required in some cases of table or data sync.
    if($skipUnInstall){
        'Skipped Uninstall-NAVApp.' | Write-Host 
    } else {  
        'Uninstalling {0} App, version {1}.' -f 
            $currentApp.Name, $currentApp.Version.ToString() | Write-Host
        
        "Uninstall-NAVApp -ServerInstance '{0}' -Name '{1}' -Version '{2}' -Tenant '{3}' -Force" -f
            $ServerInstance, $currentApp.Name, $currentApp.Version, $Tenant | 
            Write-Host -ForegroundColor Gray
        
        Uninstall-NAVApp `
            -ServerInstance $ServerInstance `
            -Name $currentApp.Name `
            -Version $currentApp.Version `
            -Tenant $Tenant `
            -Force 
    } 
    $report.Uninstall['Duration'] = (Get-Date) - $report.Uninstall.StartTime

    #-------------------------------Table sync----------------------------------

    $null = Write-StartTask 'Syncing database schema for new app.'
    $report.TableSync['StartTime'] = Get-Date

    if($skipSync){
        'Skipped Sync-NAVApp.' | Write-Host 
    } else {
        'Execute sync {0} App on server instance {1}' -f 
            $newApp.Name, $ServerInstance | Write-Host
        
        "Sync-NAVApp -ServerInstance '{0}' -Name '{1}' -Version '{2}' -Mode '{3}' -Tenant '{4}'" -f
            $ServerInstance, $newApp.Name, $newApp.Version.ToString(), 'Add', 
            $Tenant | Write-Host -ForegroundColor Gray
        
        Sync-NAVApp `
            -ServerInstance $ServerInstance `
            -Name $newApp.Name `
            -Version $newApp.Version `
            -Mode Add `
            -Tenant $Tenant
    } 
    $report.TableSync['Duration'] = (Get-Date) - $report.TableSync.StartTime

    #------------------------------Data Upgrade---------------------------------

    $null = Write-StartTask 'Execute data upgrade for new app.'
    $report.DataUpgrade['StartTime'] = Get-Date

    if($skipDataUpgrade){
        'Skipped Start-NAVAppDataUpgrade.' | Write-Host 
    } else {
        'Execute data upgrade for {0} App' -f $newApp.Name | Write-Host
        'Note: Install-NAVApp is included in Start-NAVAppDataUpgrade' | Write-Host
        "Start-NAVAppDataUpgrade -ServerInstance '{0}' -Name '{1}' -Version '{2}' -Tenant '{3}'" -f
            $ServerInstance, $newApp.Name, $newApp.Version.ToString(), $Tenant | 
            Write-Host -ForegroundColor Gray

        Start-NAVAppDataUpgrade `
            -ServerInstance $ServerInstance `
            -Name $newApp.Name `
            -Version $newApp.Version `
            -Tenant $Tenant
    } 

    $report.DataUpgrade['Duration'] = (Get-Date) - $report.DataUpgrade.StartTime

    #-------------------------------Install App---------------------------------

    $null = Write-StartTask 'Installing app if not yet installed.'
    $report.Install['StartTime'] = Get-Date

    if($skipInstall){
        'Skipped Install-NAVApp.' | Write-Host 
    } else {
        'Installing {0} App' -f $newApp.Name | Write-Host 
        "Install-NAVApp -ServerInstance '{0}' -Name '{1}' -Version '{2}' -Tenant '{3}' -Force" -f 
            $ServerInstance, $newApp.Name, $newApp.Version.ToString(), $Tenant |
            Write-Host -ForegroundColor Gray

        Install-NAVApp `
            -ServerInstance $ServerInstance `
            -Name $newApp.Name `
            -Version $newApp.Version `
            -Tenant $Tenant `
            -Force
    } 
    $report.Install['Duration'] = (Get-Date) - $report.Install.StartTime

    
    #--------------------------Set base app version-----------------------------
    if ($AppIsBaseApp){
        $null = Write-StartTask 'Updating the application version in the database for base app.'       
        
        $BaseAppStatus = Get-NavAppInfo -ServerInstance $ServerInstance -Tenant $Tenant -TenantSpecificProperties
        $BaseAppStatus = $BaseAppStatus | Where-Object {
            $_.AppId.Value.Guid -eq $newApp.Guid -and 
            $_.Version -eq $newApp.Version
        }
        $BaseAppStatus | Select-Object -Property AppId, Name, Version, SyncState, NeedsUpgrade, IsInstalled | Write-host
        
        # Only set higher application version if installation of the baseapp is successful.
        if($BaseAppStatus.IsInstalled -eq $false){
            $message = 'Updating the base app {0} version {1} failed. Installation canceled.' -f 
                            $newApp.Name, $newApp.Version
            throw $message
        }

        # Update the ApplicationVersion in the application database (the application version displayed in the webclient)
        if($newApp.Version.Major -ge '17'){
            [string] $nAppVersion = '{0}.{1}.0.0' -f $newApp.Version.Major, $newApp.Version.Minor
        } else {
            [string] $nAppVersion = $newApp.Version
        }
        $ApplicationVersion = (Get-NAVApplication -ServerInstance $ServerInstance).ApplicationVersion
        if ([version] $nAppVersion -gt [version] $ApplicationVersion){
            
            'Update {0} app version number from {1} to {2} in the application database.' -f 
                $newApp.Name, $ApplicationVersion, $nAppVersion | Write-Host   
            Set-NAVApplication `
                -ServerInstance $ServerInstance `
                -ApplicationVersion $nAppVersion `
                -Force
            
            Invoke-BcDataUpgrade -ServerInstance $ServerInstance
        } else {
            'ApplicationVersion is already {0}.' -f $ApplicationVersion | Write-Host
        }
    }

    #-------------------------Unpublish previous app----------------------------
    
    $null = Write-StartTask 'Unpublishing previous app version.'
    $report.Unpublish['StartTime'] = Get-Date

    # Unpublish the previous app if it is not installed in any tenant anymore.
    if ($skipUnpublish) {
        'Skipped Unpublish-NAVApp.' | Write-Host 
    } else {
        $appUsedBy = Get-NAVAppTenant -ServerInstance $ServerInstance `
                                      -Id $currentApp.AppId.Value `
                                      -Version $currentApp.Version
        if($appUsedBy){
            'Previous app {0} version {1} is not unpublished because it is still in use by another tenant.' -f 
                $currentApp.Name, $currentApp.Version.ToString() | Write-Host
        } else {
            'Unpublishing {0} App, version {1}' -f $currentApp.Name, $currentApp.Version | Write-Host
            "Unpublish-NAVApp -ServerInstance '{0}' -Name '{1}' -Version '{2}'" -f
                $ServerInstance, $currentApp.Name, $currentApp.Version.ToString() | 
                Write-Host -ForegroundColor Gray

            Unpublish-NAVApp `
                -ServerInstance $ServerInstance `
                -Name $currentApp.Name `
                -Version $currentApp.Version
        }
    } 
    $report.Unpublish['Duration'] = [DateTime] (Get-Date) - $report.Unpublish.StartTime

    # Write app instalation report to host
    'Total deploy time for app {0} on tenant {1} is {2} seconds.' -f 
    $newApp.Name, $Tenant, ((Get-Date) - $appDeployStartTime).Seconds | Write-Host
    
    foreach($key in $report.Keys){
        ' {0} seconds for {1}' -f 
            $report.$key.Duration.Seconds, $report.$key.title | Write-Host
    }
    
    Write-EndTask $appDeployStartTime
}

Export-ModuleMember -Function Install-BcSingleApp
function Invoke-BcDataUpgrade {
    param(
        [string] $ServerInstance,
        [string[]] $Tenants
    )

    if(-not $Tenants){
        # The dataupgrade needs to be executed for each tenant.
        # If not the other tenant state becomes 'OperationalDataUpgradePending'
        $tenants = (Get-NAVTenant -ServerInstance $ServerInstance).Id
    }

    foreach ($tenant in $tenants){
        'Run the data upgrade for tenant {0} on ServerInstance {1}.' -f 
            $tenant, $ServerInstance | Write-Host  

        ' Sync-NAVTenant in Sync mode' | Write-Host  
        
        Sync-NAVTenant `
            -ServerInstance $ServerInstance `
            -Mode Sync `
            -Tenant $tenant `
            -Force

        ' Start-NAVDataUpgrade' | Write-Host  

        $dataUpgradeParams = @{
            ServerInstance = $ServerInstance
            Tenant = $tenant
            FunctionExecutionMode = 'Serial'
            SkipUserSessionCheck = $true
            SkipAppVersionCheck = $true
            Force = $true
        }

        if (([version] $AppVersion).Major -lt '17'){
            $dataUpgradeParams["SkipCompanyInitialization"] = $true
        }

        Start-NAVDataUpgrade @dataUpgradeParams

        ' Get-NAVDataUpgrade' | Write-Host  
        
        $dataUpgradeState = 'InProgress'
        
        while ($DataUpgradeState -eq 'InProgress'){
            ' Dataupgrade status: {0}' -f $dataUpgradeState | Write-Host
            $dataUpgradeState = (Get-NAVDataUpgrade -ServerInstance $ServerInstance -Tenant $tenant).state
            Start-Sleep -Seconds 3
        }

        ' Dataupgrade status: {0}' -f $dataUpgradeState | Write-Host
    }
}