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){ $params = @{ 'ServerInstance' = $ServerInstance 'AppFilePath' = $App.Path 'Tenant' = $Tenant 'AppScope' = $App.DesiredScope 'SyncMode' = 'Add' 'PublishOnly' = $PublishOnly 'IsBaseApp' = $IsBaseApp 'KeepPreviousApp' = $KeepPreviousApp 'SkipVerification' = $SkipVerification } "Install-BcSingleApp -ServerInstance '{0}' -AppFilePath '{1}' -Tenant '{2}' -AppScope '{3}' -SyncMode '{4}' -PublishOnly:`${5} -IsBaseApp:`${6} -KeepPreviousApp:`${7} -SkipVerification:`${8}" -f $params.ServerInstance, $params.AppFilePath, $params.Tenant, $params.AppScope, $params.SyncMode, $params.PublishOnly, $params.IsBaseApp, $params.KeepPreviousApp, $params.SkipVerification | Write-Host -ForegroundColor Gray Install-BcSingleApp @params } # 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. #> |