Patch/Install-Patch.ps1

<#
.SYNOPSIS
    Installs patch to BC database
.DESCRIPTION
    Imports fob file and data about patch and included objects' versions to BC database specified in the settings of BC instance provided as a parameter.
.EXAMPLE
    Install-Patch -instanceName BC140 -path 'C:\N.7.2.1_1_00000003.zip'
    Install patch from file to instance BC140
.EXAMPLE
    "BC140_1", "BC140_2" | Install-Patch -path "C:\7.2 BC_1_00000001.zip"
    Install patch to multiple instances (BC140_1 and BC140_2)
.EXAMPLE
    Get-ChildItem "c:\temp\*" -Include "*.zip" | Install-Patch -instanceName BC140
    Install all patches from folder c:\temp to instance BC140
.EXAMPLE
    Get-ChildItem -Path "c:\patches\*" | `
        % { if ($_.Name -match "_(1|2|3)_(?:0+)([0-9]+)" -and $matches[1] -eq 1 -and $matches[2] -in 9..31) { $_ } } | `
            Install-Patch -instanceName BC140
    Install patches 9 through 31 for layer 1 from folder c:\patches to instance BC140
.NOTES
    Instances from destenation parameter must be configured to accept NTLM auth and have API endpoint enabled.
    Must be run as administrator because it requires to tun Sync-NavTenant cmdlet.
#>

function Install-Patch {
    [Alias("inpa")]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        # Destination BC instance where objects should be imported to.
        [Parameter(Mandatory = $false, ValueFromPipeline)]
        [ArgumentCompleter({ Import-NavModule -Admin; Get-NAVServerInstance | % { $_.ServerInstance -replace ".*\$", "" } })]
        [ValidateScript( { $_ -ne ""} )]
        [string]$instanceName = $PatchHelperConfig.DefaultInstance,
        # Path to patch archive.
        [Parameter(Mandatory = $true, ValueFromPipeline)]
        [ValidateScript( { $_ -ne ""} )]
        [string]$path,
        # When this switch is present all instances set to the same database will be restarted after last patch is imported.
        [switch]$restart,
        # When this switch is present current patch will be checked with the one being imported and if they are match, then current will be deleted and reimported.
        [switch]$allowReinstall,
        # Skip the 45s delay between Sync and Patch Data update request. Usefull when installing small patches in a batch.
        [switch]$noSleep,
        [switch]$allowRelease,
        [switch]$offline
    )
    begin {
        #Requires -RunAsAdministrator
        $ErrorActionPreference = "Stop"
        Import-NavModule -Service -Development
    }
    process {
        $portDest = Get-NAVServerConfiguration $instanceName -KeyName ODataServicesPort
        $bcServerDest = "localhost"

        $temparchive = "$Env:TEMP\$([System.IO.Path]::GetFileNameWithoutExtension($path))\"
        Write-Verbose $temparchive
        Expand-Archive $path -DestinationPath $temparchive -Force
        $tempFob = Get-ChildItem $temparchive -Include "*.fob" -Recurse
        $tempJson = Get-ChildItem $temparchive -Include "*.json" -Recurse

        Write-Host "Installing patch $path"
        $destBaseUrl = "http://$bcServerDest`:$portDest/$instanceName/api/v1.0/companies"
        $company = (Invoke-RestMethod -Uri ($destBaseUrl) -UseDefaultCredentials -ErrorAction Stop).value[0].id
        $serverInfo = (Invoke-RestMethod -Uri ("$destBaseUrl($company)/tfsInfos") -UseDefaultCredentials).value[0]
        $destUrl = "$destBaseUrl($company)/tfsPatches"
        # Validity checks
        $importData = Get-Content $tempJson | ConvertFrom-Json
        $current = (Invoke-RestMethod -Uri "$destUrl`?`$filter=Level eq $($importData.Level)&`$top=1&`$orderby=Number desc" -UseDefaultCredentials -Method Get -ContentType "application/json").value

        Write-Verbose ($current | Format-List | Out-String)
        if (!$allowRelease -and ($importData.Release -eq "true")) {
            throw "Patch being installed is the release patch. It can only be installed via Install-Release command."
        }
        if ([int]$current.Number -ne 0) {
            if ([int]$current.Number + 1 -ne [int]$importData.Number) {
                if ($allowReinstall) {
                    if ([int]$current.Number -ne [int]$importData.Number) {
                        throw "Patch being installed has number $($importData.Number) and latest installed has number $($current.Number). With -reinstall parameter it is only allowed to install current patch or next one. Import cancelled."
                    }
                    if ($PSCmdlet.ShouldProcess("$destUrl($($importData.ID))","Invoke-WebRequest DELETE")){
                        Invoke-WebRequest -Uri "$destUrl($($importData.ID))" -UseDefaultCredentials -Method Delete -ContentType "application/json" | Out-Null
                    }
                }
                else {
                    if ([int]$current.Number -eq [int]$importData.Number) {
                        throw "Patch $($importData.Number) being installed is already installed if you want to reinstall patch then use parameter -reinstall. Import cancelled."
                    }
                    else {
                        throw "Patch being installed has number $($importData.Number) and latest installed has number $($current.Number). Install missing patches first. Import cancelled."
                    }
                }
            }
        }

        # Import objects
        Write-Verbose "Destenation ($($serverInfo.SQLServerName)) ($($serverInfo.SQLDatabaseName)): $destUrl"
        if ($PSCmdlet.ShouldProcess("-Path $tempFob -DatabaseName $($serverInfo.SQLDatabaseName) -DatabaseServer $($serverInfo.SQLServerName)","Import-NAVApplicationObject")){
            Import-NAVApplicationObject -Path $tempFob -DatabaseName $serverInfo.SQLDatabaseName -DatabaseServer $serverInfo.SQLServerName -ImportAction Overwrite -SynchronizeSchemaChanges No -SuppressBuildSearchIndex -Confirm:$false -ErrorAction Stop
        }
        if(($importData.tfsPatchLines | where { $_.Type -eq "MenuSuite" }).Count -gt 0){
            Write-Host "MenuSuites detected in patch. Recompiling objects..."
            if ($PSCmdlet.ShouldProcess("MenuSuite","Compile-NAVApplicationObject")){
                Compile-NAVApplicationObject -DatabaseName $serverInfo.SQLDatabaseName -DatabaseServer $serverInfo.SQLServerName `
                    -Recompile -Filter 'Type=MenuSuite' -SynchronizeSchemaChanges No -ErrorAction Continue
            }
        }
        if ($PSCmdlet.ShouldProcess($instanceName,"Sync-NAVTenant")){
            $ProgressPreferenceBack = $ProgressPreference; $ProgressPreference = "SilentlyContinue";
            Sync-NAVTenant -ServerInstance $instanceName -Mode Sync -Force -ErrorAction SilentlyContinue -ErrorVariable syncError
            $ProgressPreference = $ProgressPreferenceBack;
        }

        # Update version data
        if (-not $noSleep) { Start-Sleep -Seconds 45 } # Wait to avoid (503) Server Unavailable

        if ($PSCmdlet.ShouldProcess($destUrl,"Invoke-WebRequest")){
            Invoke-WebRequest -Uri $destUrl -UseDefaultCredentials -Method Post -ContentType "application/json" -InFile $tempJson | Out-Null
        }

        # (optionally) Report to mothership about patch installation
        if (!$offline) {
            if (!$PatchHelperConfig.CustomerName -or !$PatchHelperConfig.ReportURL -or !$PatchHelperConfig.DefaultInstance) {
                Write-Warning "Installation reporting is not configured. Please run Register-PatchHelper or use -offline switch"
            } else {
                if ($PatchHelperConfig.DefaultInstance -eq $instanceName) {
                    if ($PSCmdlet.ShouldProcess("-patchLevel ""$($importData.Level)"" -patchNumber ""$($importData.Number)""", "Send-PatchInfo")) {
                        Send-PatchInfo -patchLevel $importData.Level -patchNumber $importData.Number
                    }
                }
            }
        }

        if ($syncError) {
            Write-Error "Patch ($([System.IO.Path]::GetFileName($path))) installed but schema was not synced due to error:
                    $syncError"

        }
        else {
            Write-Host "Patch ($([System.IO.Path]::GetFileName($path))) installed successfully."
        }
    }
    end {
        if ($restart) {
            $instanceName, (Get-AdjacentInstances $instanceName) | % {
                Write-Host "Restarting instance $_" -ForegroundColor Cyan
                if ($PSCmdlet.ShouldProcess($_,"Restart-NAVServerInstance")){
                    Restart-NAVServerInstance $_ -ErrorAction Continue | Out-Null
                }
            }
        }
    }
}

<#
.SYNOPSIS
    Installs all applicable patches from folder
.DESCRIPTION
    Checks latest installed patch on instance, searches for patches in folder and if next patch can be installed then starts installation of patches one-by-one.
    If next patch to be installed has number that is not subsequent then process stops.
.EXAMPLE
    Install-PatchMnogo -instanceName BC140 -folder "C:\Patches" -noSleep
    Install patches from folder to instance BC140
.NOTES
    Instances from destenation parameter must be configured to accept NTLM auth and have API endpoint enabled.
    Must be run as administrator because it requires to tun Sync-NavTenant cmdlet.
#>

function Install-PatchMnogo {
    [alias("inpamg")]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        # Destination BC instance where objects should be imported to.
        [Parameter(Mandatory = $false, ValueFromPipeline)]
        [ArgumentCompleter({ Import-NavModule -Admin; Get-NAVServerInstance | % { $_.ServerInstance -replace ".*\$", "" } })]
        [ValidateScript( { $_ -ne ""} )]
        [string]$instanceName = $PatchHelperConfig.DefaultInstance,
        # Path to the folder with patch archives.
        [Parameter(Mandatory = $false, ValueFromPipeline)]
        [ValidateScript( { $_ -ne ""} )]
        [string]$folder = $PatchHelperConfig.DefaultPatchFolder,
        # Level of the patch you interested in. Defaults to 1.
        [int]$level = 1,
        # When this switch is present all instances set to the same database will be restarted after last patch is imported.
        [switch]$restart,
        # Skip the 45s delay between Sync and Patch Data update request. Usefull when installing small patches in a batch.
        [switch]$noSleep
    )
    begin {
        #Requires -RunAsAdministrator
        $ErrorActionPreference = "Stop"
        Import-NavModule -Service -Development

        $portDest = Get-NAVServerConfiguration $instanceName -KeyName ODataServicesPort
        $PatchInfo = Get-PatchInfo -instanceName $instanceName -port $portDest -level $level
        [int]$num = if (!$PatchInfo) { 0 } else { $PatchInfo.Number }
        Write-Verbose "Latest patch installed is $num"
    }
    process{
        $num += 1
        $found = @()
        $found = Get-ChildItem -Path "$folder\*" -Filter "*.zip" | `
            % { if ($_.Name -match "_(1|2|3)_(?:0+)([0-9]+)" -and $matches[1] -eq $level -and $matches[2] -in $num..10000) { @{ Number = $matches[2]; Path = $_.FullName } } }
        if (!$found) {
            Write-Error "No patches found."
        }
        if ($found[0].Number -ne $num) {
            Write-Error "Next patch is not found. Latest installed is $($num - 1) first found is $($found[0].Number)."
        }
        Write-Verbose "Found $($found.Count) patches on level $level"

        $num = $found[0].Number
        $found | % {
            if ($_.Number -ne $num) {
                Write-Warning "Processing has stopped because subsequent patch $num was not found in the folder."
                break;
            }
            if ($PSCmdlet.ShouldProcess("$($_.Path)","Install-Patch"))
                { Install-Patch -path $_.Path -instanceName $instanceName -restart:$restart -allowReinstall:$false -noSleep:$noSleep }
            $num++
        }
    }
}

Export-ModuleMember -Alias * -Function *