288 строки
14 KiB
PowerShell
288 строки
14 KiB
PowerShell
<#
|
|
.Synopsis
|
|
Function for publishing PTE apps to an online tenant
|
|
.Description
|
|
Function for publishing PTE apps to an online tenant
|
|
Please consult the CI/CD Workshop document at http://aka.ms/cicdhol to learn more about this function
|
|
.Parameter clientId
|
|
ClientID of Azure AD App for authenticating to Business Central (SecureString or String)
|
|
.Parameter clientSecret
|
|
ClientSecret of Azure AD App for authenticating to Business Central (SecureString or String)
|
|
.Parameter tenantId
|
|
TenantId of tenant in which you want to publish the Per Tenant Extension Apps
|
|
.Parameter environment
|
|
Name of the environment inside the tenant in which you want to publish the Per Tenant Extension Apps
|
|
.Parameter companyName
|
|
Company Name in which the Azure AD App is registered
|
|
.Parameter appFiles
|
|
Array or comma separated string of apps or .zip files containing apps, which needs to be published
|
|
The apps will be sorted by dependencies and published+installed
|
|
.Parameter useNewLine
|
|
Add this switch to add a newline to progress indicating periods during wait.
|
|
Azure DevOps doesn't update logs until a newline is added.
|
|
.Parameter hideInstalledExtensionsOutput
|
|
Add this parameter to hide the output that lists installed extensions on the specified environment before and after installation of new and updated PTE extensions.
|
|
#>
|
|
function Publish-PerTenantExtensionApps {
|
|
[CmdletBinding(DefaultParameterSetName="AC")]
|
|
Param(
|
|
[Parameter(Mandatory=$true, ParameterSetName="CC")]
|
|
$clientId,
|
|
[Parameter(Mandatory=$true, ParameterSetName="CC")]
|
|
$clientSecret,
|
|
[Parameter(Mandatory=$true, ParameterSetName="CC")]
|
|
[string] $tenantId,
|
|
[Parameter(Mandatory=$true, ParameterSetName="AC")]
|
|
[Hashtable] $bcAuthContext,
|
|
[Parameter(Mandatory=$true)]
|
|
[string] $environment,
|
|
[Parameter(Mandatory=$false)]
|
|
[string] $companyName,
|
|
[Parameter(Mandatory=$true)]
|
|
$appFiles,
|
|
[ValidateSet('Add','Force')]
|
|
[string] $schemaSyncMode = 'Add',
|
|
[ValidateSet('','Current version','Next minor version','Next major version')]
|
|
[string] $schedule = '',
|
|
[switch] $useNewLine,
|
|
[switch] $hideInstalledExtensionsOutput
|
|
)
|
|
|
|
$telemetryScope = InitTelemetryScope -name $MyInvocation.InvocationName -parameterValues $PSBoundParameters -includeParameters @()
|
|
try {
|
|
|
|
function GetAuthHeaders {
|
|
$script:authContext = Renew-BcAuthContext -bcAuthContext $script:authContext
|
|
return @{ "Authorization" = "Bearer $($script:authContext.AccessToken)" }
|
|
}
|
|
|
|
$newLine = @{}
|
|
if (!$useNewLine) {
|
|
$newLine = @{ "NoNewLine" = $true }
|
|
}
|
|
|
|
if ($PsCmdlet.ParameterSetName -eq "CC") {
|
|
if ($clientId -is [SecureString]) { $clientID = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($clientID)) }
|
|
if ($clientId -isnot [String]) { throw "ClientID needs to be a SecureString or a String" }
|
|
if ($clientSecret -is [String]) { $clientSecret = ConvertTo-SecureString -String $clientSecret -AsPlainText -Force }
|
|
if ($clientSecret -isnot [SecureString]) { throw "ClientSecret needs to be a SecureString or a String" }
|
|
|
|
$script:authContext = New-BcAuthContext `
|
|
-clientID $clientID `
|
|
-clientSecret $clientSecret `
|
|
-tenantID $tenantId `
|
|
-scopes "https://api.businesscentral.dynamics.com/.default"
|
|
|
|
if (-not ($script:AuthContext)) {
|
|
throw "Authentication failed"
|
|
}
|
|
}
|
|
else {
|
|
$script:authContext = Renew-BcAuthContext -bcAuthContext $bcAuthContext
|
|
}
|
|
|
|
$appFolder = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString())
|
|
try {
|
|
$appFiles = CopyAppFilesToFolder -appFiles $appFiles -folder $appFolder
|
|
$automationApiUrl = "$($bcContainerHelperConfig.apiBaseUrl.TrimEnd('/'))/v2.0/$environment/api/microsoft/automation/v2.0"
|
|
|
|
Write-Host "$automationApiUrl/companies"
|
|
$companies = Invoke-RestMethod -Headers (GetAuthHeaders) -Method Get -Uri "$automationApiUrl/companies" -UseBasicParsing
|
|
$company = $companies.value | Where-Object { ($companyName -eq "") -or ($_.name -eq $companyName) } | Select-Object -First 1
|
|
if (!($company)) {
|
|
throw "No company $companyName"
|
|
}
|
|
$companyId = $company.id
|
|
if ($companyName -eq "") {
|
|
$companyName = $company.name
|
|
}
|
|
Write-Host "Company '$companyName' has id $companyId"
|
|
|
|
Write-Host "$automationApiUrl/companies($companyId)/extensions"
|
|
$getExtensions = Invoke-WebRequest -Headers (GetAuthHeaders) -Method Get -Uri "$automationApiUrl/companies($companyId)/extensions" -UseBasicParsing
|
|
$extensions = (ConvertFrom-Json $getExtensions.Content).value | Sort-Object -Property DisplayName
|
|
|
|
if(!$hideInstalledExtensionsOutput) {
|
|
Write-Host "Extensions before:"
|
|
$extensions | ForEach-Object { Write-Host " - $($_.DisplayName), Version $($_.versionMajor).$($_.versionMinor).$($_.versionBuild).$($_.versionRevision), Installed=$($_.isInstalled)" }
|
|
Write-Host
|
|
}
|
|
|
|
$body = @{"schedule" = "Current Version"}
|
|
$appDep = $extensions | Where-Object { $_.DisplayName -eq 'Application' }
|
|
$appDepVer = [System.Version]"$($appDep.versionMajor).$($appDep.versionMinor).$($appDep.versionBuild).$($appDep.versionRevision)"
|
|
if ($appDepVer -ge [System.Version]"21.2.0.0") {
|
|
if ($schemaSyncMode -eq 'Force') {
|
|
$body."SchemaSyncMode" = "Force Sync"
|
|
}
|
|
else {
|
|
$body."SchemaSyncMode" = "Add"
|
|
}
|
|
}
|
|
else {
|
|
if ($schemaSyncMode -eq 'Force') {
|
|
throw 'SchemaSyncMode Force is not supported before version 21.2'
|
|
}
|
|
}
|
|
|
|
if($schedule) {
|
|
$body."schedule" = $schedule
|
|
}
|
|
|
|
$ifMatchHeader = @{ "If-Match" = '*'}
|
|
$jsonHeader = @{ "Content-Type" = 'application/json'}
|
|
$streamHeader = @{ "Content-Type" = 'application/octet-stream'}
|
|
try {
|
|
Sort-AppFilesByDependencies -appFiles $appFiles -excludeRuntimePackages | ForEach-Object {
|
|
Write-Host @newline "$([System.IO.Path]::GetFileName($_)) - "
|
|
$appJson = Get-AppJsonFromAppFile -appFile $_
|
|
|
|
$existingApp = $extensions | Where-Object { $_.id -eq $appJson.id -and $_.isInstalled }
|
|
if ($existingApp) {
|
|
if ($existingApp.isInstalled) {
|
|
$existingVersion = [System.Version]"$($existingApp.versionMajor).$($existingApp.versionMinor).$($existingApp.versionBuild).$($existingApp.versionRevision)"
|
|
if ($existingVersion -ge $appJson.version) {
|
|
Write-Host "already installed"
|
|
}
|
|
else {
|
|
Write-Host @newLine "upgrading"
|
|
$existingApp = $null
|
|
}
|
|
}
|
|
else {
|
|
Write-Host @newLine "installing"
|
|
$existingApp = $null
|
|
}
|
|
}
|
|
else {
|
|
Write-Host @newLine "publishing and installing"
|
|
}
|
|
if (!$existingApp) {
|
|
$extensionUpload = (Invoke-RestMethod -Method Get -Uri "$automationApiUrl/companies($companyId)/extensionUpload" -Headers (GetAuthHeaders)).value
|
|
Write-Host @newLine "."
|
|
if ($extensionUpload -and $extensionUpload.systemId) {
|
|
$extensionUpload = Invoke-RestMethod `
|
|
-Method Patch `
|
|
-Uri "$automationApiUrl/companies($companyId)/extensionUpload($($extensionUpload.systemId))" `
|
|
-Headers ((GetAuthHeaders) + $ifMatchHeader + $jsonHeader) `
|
|
-Body ($body | ConvertTo-Json -Compress)
|
|
}
|
|
else {
|
|
$ExtensionUpload = Invoke-RestMethod `
|
|
-Method Post `
|
|
-Uri "$automationApiUrl/companies($companyId)/extensionUpload" `
|
|
-Headers ((GetAuthHeaders) + $jsonHeader) `
|
|
-Body ($body | ConvertTo-Json -Compress)
|
|
}
|
|
Write-Host @newLine "."
|
|
if ($null -eq $extensionUpload.systemId) {
|
|
throw "Unable to upload extension"
|
|
}
|
|
$fileBody = [System.IO.File]::ReadAllBytes($_)
|
|
Invoke-RestMethod `
|
|
-Method Patch `
|
|
-Uri $extensionUpload.'extensionContent@odata.mediaEditLink' `
|
|
-Headers ((GetAuthHeaders) + $ifMatchHeader + $streamHeader) `
|
|
-Body $fileBody | Out-Null
|
|
Write-Host @newLine "."
|
|
Invoke-RestMethod `
|
|
-Method Post `
|
|
-Uri "$automationApiUrl/companies($companyId)/extensionUpload($($extensionUpload.systemId))/Microsoft.NAV.upload" `
|
|
-Headers ((GetAuthHeaders) + $ifMatchHeader) `
|
|
-ErrorAction SilentlyContinue | Out-Null
|
|
Write-Host @newLine "."
|
|
$completed = $false
|
|
$errCount = 0
|
|
$sleepSeconds = 30
|
|
$lastStatus = ''
|
|
while (!$completed)
|
|
{
|
|
Start-Sleep -Seconds $sleepSeconds
|
|
try {
|
|
$extensionDeploymentStatusResponse = Invoke-WebRequest -Headers (GetAuthHeaders) -Method Get -Uri "$automationApiUrl/companies($companyId)/extensionDeploymentStatus" -UseBasicParsing
|
|
$extensionDeploymentStatuses = (ConvertFrom-Json $extensionDeploymentStatusResponse.Content).value
|
|
|
|
$thisExtension = $extensionDeploymentStatuses | Where-Object { $_.publisher -eq $appJson.publisher -and $_.name -eq $appJson.name -and $_.appVersion -eq $appJson.version }
|
|
if ($null -eq $thisExtension) {
|
|
throw "Unable to find extension deployment status"
|
|
}
|
|
$thisExtension | ForEach-Object {
|
|
if ($_.status -ne $lastStatus) {
|
|
if (!$useNewLine) { Write-Host }
|
|
Write-Host @newLine $_.status
|
|
$lastStatus = $_.status
|
|
}
|
|
if ($_.status -eq "InProgress") {
|
|
$errCount = 0
|
|
$sleepSeconds = 5
|
|
Write-Host @newLine "."
|
|
}
|
|
elseif ($_.Status -eq "Unknown") {
|
|
throw "Unknown Error"
|
|
}
|
|
elseif ($_.Status -eq "Completed") {
|
|
if (!$useNewLine) { Write-Host }
|
|
$completed = $true
|
|
}
|
|
else {
|
|
$errCount = 5
|
|
throw $_.status
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
if (!$useNewLine) { Write-Host }
|
|
if ($errCount++ -gt 4) {
|
|
Write-Host $_.Exception.Message
|
|
throw "Unable to publish app. Please open the Extension Deployment Status Details page in Business Central to see the detailed error message."
|
|
}
|
|
$sleepSeconds += $sleepSeconds
|
|
Write-Host "Error: $($_.Exception.Message). Retrying in $sleepSeconds seconds"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch [System.Net.WebException],[System.Net.Http.HttpRequestException] {
|
|
if (!$useNewLine) { Write-Host }
|
|
Write-Host "ERROR $($_.Exception.Message)"
|
|
Write-Host $_.ScriptStackTrace
|
|
throw (GetExtendedErrorMessage $_)
|
|
}
|
|
catch {
|
|
if (!$useNewLine) { Write-Host }
|
|
Write-Host "ERROR: $($_.Exception.Message) [$($_.Exception.GetType().FullName)]"
|
|
throw
|
|
}
|
|
finally {
|
|
$getExtensions = Invoke-WebRequest -Headers (GetAuthHeaders) -Method Get -Uri "$automationApiUrl/companies($companyId)/extensions" -UseBasicParsing
|
|
$extensions = (ConvertFrom-Json $getExtensions.Content).value | Sort-Object -Property DisplayName
|
|
|
|
if (!$hideInstalledExtensionsOutput) {
|
|
Write-Host
|
|
Write-Host "Extensions after:"
|
|
$extensions | ForEach-Object { Write-Host " - $($_.DisplayName), Version $($_.versionMajor).$($_.versionMinor).$($_.versionBuild).$($_.versionRevision), Installed=$($_.isInstalled)" }
|
|
}
|
|
}
|
|
}
|
|
catch [System.Net.WebException],[System.Net.Http.HttpRequestException] {
|
|
Write-Host "ERROR $($_.Exception.Message)"
|
|
throw (GetExtendedErrorMessage $_)
|
|
}
|
|
finally {
|
|
if (Test-Path $appFolder) {
|
|
Remove-Item $appFolder -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
TrackException -telemetryScope $telemetryScope -errorRecord $_
|
|
throw
|
|
}
|
|
finally {
|
|
$script:authContext = $null
|
|
TrackTrace -telemetryScope $telemetryScope
|
|
}
|
|
}
|
|
Export-ModuleMember -Function Publish-PerTenantExtensionApps
|