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 {
    [CmdletBinding()]
    param(
        # Destination BC instance where objects should be imported to.
        [Parameter(Mandatory = $True, ValueFromPipeline)]
        [string]$instanceName,
        # Path to patch archive.
        [Parameter(Mandatory = $true, ValueFromPipeline)]
        [string]$path,
        # When this switch is present all instances set to the same database will be restarted after last patch is imported.
        [Parameter(Mandatory = $false)]
        [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.
        [Parameter(Mandatory = $false)]
        [switch]$allowReinstall,
        # Skip the 45s delay between Sync and Patch Data update request. Usefull when installing small patches in a batch.
        [Parameter(Mandatory = $false)]
        [switch]$noSleep
    )
    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 ([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 imported 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."
                    }
                    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 imported is already installed if you want to reinstall patch then use parameter -reinstall. Import cancelled."
                    }
                    else {
                        throw "Patch being imported has number $($importData.Number) and latest installed has number $($current.Number). Install missing patches first. Import cancelled."
                    }
                }
            }
        }

        # Import objects
        Write-Host "Destenation ($($serverInfo.SQLServerName)) ($($serverInfo.SQLDatabaseName)): $destUrl" -ForegroundColor Green
        Import-NAVApplicationObject -Path $tempFob -DatabaseName $serverInfo.SQLDatabaseName -DatabaseServer $serverInfo.SQLServerName -ImportAction Overwrite -SynchronizeSchemaChanges No -SuppressBuildSearchIndex -Confirm:$false -ErrorAction Stop
        $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

        Invoke-WebRequest -Uri $destUrl -UseDefaultCredentials -Method Post -ContentType "application/json" -InFile $tempJson | Out-Null

        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." -ForegroundColor Cyan
        }
    }
    end {
        if ($restart) {
            $current = Get-NAVApplication $instanceName
            Get-NAVServerInstance | Where-Object -Property State -EQ -Value Running | % {
                $app = Get-NAVApplication $_.ServerInstance
                if ($app.'Database server' -eq $current.'Database server' -and $app.'Database name' -eq $current.'Database name') { $_ }
            } | % {
                Write-Host "Restarting instance $($_.ServerInstance)" -ForegroundColor Cyan
                Restart-NAVServerInstance $_.ServerInstance -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 {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        # Destination BC instance where objects should be imported to.
        [Parameter(Mandatory = $True, ValueFromPipeline)]
        [string]$instanceName,
        # Path to patch archive.
        [Parameter(Mandatory = $true, ValueFromPipeline)]
        [string]$folder,
        # Level of the patch you interested in
        [Parameter(Mandatory = $false)]
        [int]$level = 1,
        # When this switch is present all instances set to the same database will be restarted after last patch is imported.
        [Parameter(Mandatory = $false)]
        [switch]$restart,
        # Skip the 45s delay between Sync and Patch Data update request. Usefull when installing small patches in a batch.
        [Parameter(Mandatory = $false)]
        [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 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."
                break;
            }
            if ($PSCmdlet.ShouldProcess("$($_.Path)","Install-Patch"))
                { Install-Patch -path $_.Path -instanceName $instanceName -restart:$restart -allowReinstall:$false -noSleep:$noSleep }
            $num++
        }
    }
}