Modules/businessdev.ALbuild.Apps/Public/Test-BcObjectIdConsistency.ps1
|
function Test-BcObjectIdConsistency { <# .SYNOPSIS Checks the object IDs a workspace uses: duplicates across its apps, and - with -Ninja - whether AL Object ID Ninja would hand out an ID the repository already uses. .DESCRIPTION Ninja reserves IDs in its own backend. When the repository and Ninja drift apart - an object created without Ninja, a branch merged with IDs Ninja never saw - Ninja hands out an ID that is already in use, and the clash surfaces as a compile error on someone else's branch (Banking #67152). * Two apps of the workspace that declare the same object type and ID collide in one tenant; the compiler only sees one app at a time. * With -Ninja, the backend is asked for the next ID per object type WITHOUT committing it (the preview the Ninja extension shows) - the same request the Ninja client sends, for each app's ranges (.objidconfig 'objectRanges' / 'idRanges', else app.json 'idRanges') and pool (.objidconfig 'appPoolId', else SHA-256 of the app id). An answer that is already used in the repository means Ninja's reservations lag behind it. Everything here only warns: an ID check must never fail a build. .PARAMETER WorkspaceRoot The repository root; every AL project below it is checked (albuild.json 'excludeProjects' honoured). .PARAMETER Ninja Also ask the Ninja backend. This sends the app's id hash, name, publisher and version to it, as the Ninja extension does. .PARAMETER NinjaBackendUrl The Ninja backend. Default https://backend.alid.ninja. .OUTPUTS PSCustomObject: Passed, ObjectCount, NinjaChecked, Findings[] { Kind, ObjectType, Id, App, Files[], Message }. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $WorkspaceRoot, [switch] $Ninja, [string] $NinjaBackendUrl = 'https://backend.alid.ninja' ) $root = (Resolve-Path -LiteralPath $WorkspaceRoot).Path $exclude = @() try { $config = Get-ALbuildProjectConfig -AppFolder $root -WorkspaceRoot $root if ($config.Has('ExcludeProjects')) { $exclude = @($config.ExcludeProjects) } } catch { Write-ALbuildLog -Level Verbose "No albuild.json at '$root': $($_.Exception.Message)" } $projects = @(Get-BcProjectBuildOrder -Path $root -ExcludeProjects $exclude) $declRx = [regex]::new('(?im)^\s*(codeunit|table|page|report|xmlport|query|enum|pageextension|tableextension|reportextension|enumextension|permissionset|permissionsetextension)\s+(\d+)\s') $byKey = @{} # 'type:id' -> list of { App, File } $byApp = @{} # app folder -> @{ type -> HashSet[int] } $objectCount = 0 foreach ($p in $projects) { $used = @{} foreach ($file in Get-ChildItem -LiteralPath $p.Folder -Filter '*.al' -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.FullName.Substring($p.Folder.Length) -notmatch '[\\/](\.alpackages|\.output|output|\.snapshots)[\\/]' }) { $text = Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue if (-not $text) { continue } $m = $declRx.Match($text) if (-not $m.Success) { continue } $type = $m.Groups[1].Value.ToLowerInvariant() $id = [int]$m.Groups[2].Value $objectCount++ if (-not $used.ContainsKey($type)) { $used[$type] = [System.Collections.Generic.HashSet[int]]::new() } [void]$used[$type].Add($id) $key = "$($type):$id" if (-not $byKey.ContainsKey($key)) { $byKey[$key] = [System.Collections.Generic.List[object]]::new() } $rel = if ($file.FullName.StartsWith($p.Folder, [System.StringComparison]::OrdinalIgnoreCase)) { (Split-Path -Path $p.Folder -Leaf) + '/' + $file.FullName.Substring($p.Folder.Length).TrimStart('\', '/').Replace('\', '/') } else { $file.FullName } $byKey[$key].Add([PSCustomObject]@{ App = $p.Name; File = $rel }) } $byApp[$p.Folder] = $used } $findings = [System.Collections.Generic.List[object]]::new() foreach ($key in ($byKey.Keys | Sort-Object)) { $apps = @($byKey[$key] | ForEach-Object App | Select-Object -Unique) if ($apps.Count -lt 2) { continue } $type, $id = $key -split ':' $findings.Add([PSCustomObject]@{ Kind = 'Duplicate'; ObjectType = $type; Id = [int]$id; App = ($apps -join ', ') Files = @($byKey[$key] | ForEach-Object File) Message = "$type $id is declared by $($apps.Count) apps ($($apps -join ', ')); they cannot be installed side by side." }) } $ninjaGaps = 0 if ($Ninja) { # The backend refuses a request without the git user's e-mail (GIT_EMAIL_REQUIRED), as the Ninja # client sends it; read the same values it does. $gitName = "$(& git -C $root config user.name 2>$null)".Trim() $gitEmail = "$(& git -C $root config user.email 2>$null)".Trim().ToLowerInvariant() if (-not $gitEmail) { Write-ALbuildLog -Level Warning "Object ID Ninja needs the git user's e-mail ('git config user.email'); none is set in '$root', so Ninja was not asked." $ninjaGaps++ } } if ($Ninja -and $gitEmail) { foreach ($p in $projects) { $manifest = Get-Content -LiteralPath (Join-Path $p.Folder 'app.json') -Raw | ConvertFrom-Json $objIdConfig = $null $cfgPath = Join-Path $p.Folder '.objidconfig' if (Test-Path -LiteralPath $cfgPath) { # .objidconfig allows comments; strip line comments before parsing. $objIdConfig = ((Get-Content -LiteralPath $cfgPath) -replace '^\s*//.*$', '') -join "`n" | ConvertFrom-Json } $cfgValue = { param($n) if ($objIdConfig -and ($objIdConfig.PSObject.Properties.Name -contains $n)) { $objIdConfig.$n } else { $null } } $poolId = & $cfgValue 'appPoolId' if (-not $poolId) { $sha = [System.Security.Cryptography.SHA256]::Create() try { $poolId = -join ($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes("$($manifest.id)")) | ForEach-Object { $_.ToString('x2') }) } finally { $sha.Dispose() } } $headers = @{ 'Ninja-App-Id' = "$($manifest.id)" 'Ninja-Header-Payload' = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((@{ gitUserName = $gitName; gitUserEmail = $gitEmail appPublisher = "$($manifest.publisher)"; appName = "$($manifest.name)"; appVersion = "$($manifest.version)" ninjaVersion = '3.1.0'; source = 'albuild' } | ConvertTo-Json -Compress))) } $authKey = & $cfgValue 'authKey' if ($authKey) { $headers['Ninja-Auth-Key'] = "$authKey" } foreach ($type in ($byApp[$p.Folder].Keys | Sort-Object)) { $objectRanges = & $cfgValue 'objectRanges' $ranges = if ($objectRanges -and ($objectRanges.PSObject.Properties.Name -contains $type)) { @($objectRanges.$type) } elseif (& $cfgValue 'idRanges') { @(& $cfgValue 'idRanges') } else { @($manifest.idRanges) } $ranges = @($ranges | Where-Object { $_ } | ForEach-Object { [PSCustomObject]@{ from = [int]$_.from; to = [int]$_.to } }) if ($ranges.Count -eq 0) { continue } $body = @{ appId = $poolId; type = $type; ranges = $ranges; commit = $false; perRange = $true } | ConvertTo-Json -Depth 5 -Compress try { $answer = Invoke-RestMethod -Method Post -Uri "$($NinjaBackendUrl.TrimEnd('/'))/api/v3/getNext/$poolId" -Headers $headers ` -ContentType 'application/json' -Body $body -TimeoutSec 20 -ErrorAction Stop } catch { Write-ALbuildLog -Level Warning "Object ID Ninja could not be asked about $($p.Name) ($type): $($_.Exception.Message). The ID check against Ninja is incomplete." $ninjaGaps++ continue } if (-not $answer -or -not ($answer.PSObject.Properties.Name -contains 'id')) { continue } foreach ($next in @($answer.id)) { if ($null -eq $next -or -not $byApp[$p.Folder][$type].Contains([int]$next)) { continue } $owner = @($byKey["$($type):$next"] | Where-Object App -EQ $p.Name | ForEach-Object File) $findings.Add([PSCustomObject]@{ Kind = 'NinjaStale'; ObjectType = $type; Id = [int]$next; App = $p.Name; Files = $owner Message = "Object ID Ninja would hand out $type $next for $($p.Name), but $($owner -join ', ') already uses it. Ninja's reservations lag behind the repository; synchronise them ('Ninja: Synchronize Object IDs with the Azure back end')." }) } } } } foreach ($f in $findings) { Write-ALbuildLog -Level Warning $f.Message } if ($findings.Count -eq 0) { $ninjaText = if (-not $Ninja) { '' } elseif ($ninjaGaps -gt 0) { "; Ninja could not be asked $ninjaGaps time(s)" } else { ', Ninja in step with the repository' } Write-ALbuildLog -Level Success ("Object IDs: {0} object(s) in {1} app(s), no conflict{2}." -f $objectCount, $projects.Count, $ninjaText) } return [PSCustomObject]@{ Passed = ($findings.Count -eq 0); ObjectCount = $objectCount; NinjaChecked = [bool]($Ninja -and $ninjaGaps -eq 0); Findings = @($findings) } } |