scripts/Sync-AlzQueries.ps1
|
#Requires -Version 7.4 <# .SYNOPSIS Sync canonical ALZ query JSON from upstream into local queries/alz/. .DESCRIPTION Reads tools/tool-manifest.json to resolve the alz-queries upstream repo, clones upstream via modules/shared/RemoteClone.ps1, and syncs queries/alz/alz_additional_queries.json in an idempotent way. #> [CmdletBinding()] param( [string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, [string]$ManifestPath, [string]$ToolName = 'alz-queries', [string]$SourceRelativePath = 'queries\alz_additional_queries.json', [string]$DestinationRelativePath = 'queries\alz\alz_additional_queries.json', [switch]$DryRun ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if (-not $ManifestPath) { $ManifestPath = Join-Path $RepoRoot 'tools\tool-manifest.json' } . (Join-Path $RepoRoot 'modules\shared\Sanitize.ps1') . (Join-Path $RepoRoot 'modules\shared\Retry.ps1') . (Join-Path $RepoRoot 'modules\shared\RemoteClone.ps1') . (Join-Path $RepoRoot 'modules\shared\Installer.ps1') function Resolve-UpstreamRepoUrl { param([Parameter(Mandatory)][string]$Repo) if ($Repo -match '^https://') { return $Repo.TrimEnd('/') } $normalized = $Repo.Trim('/') return "https://github.com/$normalized" } function Throw-SyncInstallerError { param( [Parameter(Mandatory)][string]$Reason, [Parameter(Mandatory)][string]$Category, [string]$Details, [string]$Url, [string]$Remediation = 'Verify tools/tool-manifest.json, git connectivity, and upstream query file path.' ) $err = New-InstallerError ` -Tool $ToolName ` -Kind 'gitclone' ` -Reason $Reason ` -Category $Category ` -Url $Url ` -Remediation $Remediation ` -Output (Remove-Credentials -Text ([string]$Details)) throw $err } function Invoke-SyncAlzQueries { [CmdletBinding()] param( [Parameter(Mandatory)][string]$RepoRootPath, [Parameter(Mandatory)][string]$ManifestFilePath, [Parameter(Mandatory)][string]$SelectedToolName, [Parameter(Mandatory)][string]$SourceRelativeFilePath, [Parameter(Mandatory)][string]$DestinationPathRelative, [switch]$WhatIfDryRun ) if (-not (Test-Path -LiteralPath $ManifestFilePath)) { Throw-SyncInstallerError -Reason 'Manifest file not found.' -Category 'ManifestMissing' -Details $ManifestFilePath } $manifest = $null try { $manifest = Get-Content -LiteralPath $ManifestFilePath -Raw | ConvertFrom-Json -ErrorAction Stop } catch { Throw-SyncInstallerError -Reason 'Failed to parse manifest JSON.' -Category 'ManifestParseFailed' -Details $_.Exception.Message } $toolEntry = @($manifest.tools | Where-Object { $_.name -eq $SelectedToolName } | Select-Object -First 1) if ($toolEntry.Count -eq 0) { Throw-SyncInstallerError -Reason "Tool '$SelectedToolName' not found in manifest." -Category 'ToolMissing' -Details $ManifestFilePath } $upstreamRepoRef = [string]$toolEntry[0].upstream.repo if ([string]::IsNullOrWhiteSpace($upstreamRepoRef)) { Throw-SyncInstallerError -Reason "Tool '$SelectedToolName' is missing upstream.repo." -Category 'UpstreamMissing' -Details $ManifestFilePath } $upstreamUrl = Resolve-UpstreamRepoUrl -Repo $upstreamRepoRef if (-not (Test-RemoteRepoUrl -Url $upstreamUrl)) { Throw-SyncInstallerError -Reason 'Upstream URL failed HTTPS/allow-list validation.' -Category 'UnsafeUpstream' -Details $upstreamUrl -Url $upstreamUrl } if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Throw-SyncInstallerError -Reason 'git CLI is required for query sync.' -Category 'MissingDependency' -Details 'Install git and rerun.' } Write-Verbose (Remove-Credentials -Text "[sync-alz-queries] Upstream: $upstreamUrl") try { Invoke-WithRetry -MaxAttempts 3 -InitialDelaySeconds 1 -MaxDelaySeconds 5 -ScriptBlock { $lsRemote = Invoke-WithTimeout -Command 'git' -Arguments @('ls-remote', '--heads', $upstreamUrl) -TimeoutSec 60 if ($lsRemote.ExitCode -ne 0) { throw [System.Exception]::new("git ls-remote failed: $($lsRemote.Output)") } return $true } | Out-Null } catch { Throw-SyncInstallerError -Reason 'Failed to reach upstream repository.' -Category 'UpstreamUnavailable' -Details $_.Exception.Message -Url $upstreamUrl -Remediation 'Check network access and repository visibility, then retry.' } $clone = $null try { $clone = Invoke-WithRetry -MaxAttempts 3 -InitialDelaySeconds 1 -MaxDelaySeconds 10 -ScriptBlock { $cloneResult = Invoke-RemoteRepoClone -RepoUrl $upstreamUrl -TimeoutSec 120 if ($null -eq $cloneResult -or [string]::IsNullOrWhiteSpace([string]$cloneResult.Path)) { throw [System.Exception]::new("Timed out while cloning $upstreamUrl") } return $cloneResult } } catch { Throw-SyncInstallerError -Reason 'Failed to clone upstream repository.' -Category 'CloneFailed' -Details $_.Exception.Message -Url $upstreamUrl } try { $sourcePath = Join-Path $clone.Path $SourceRelativeFilePath $destinationPath = Join-Path $RepoRootPath $DestinationPathRelative $destinationDir = Split-Path -Parent $destinationPath Write-Verbose (Remove-Credentials -Text "[sync-alz-queries] Source: $sourcePath") Write-Verbose (Remove-Credentials -Text "[sync-alz-queries] Destination: $destinationPath") if (-not (Test-Path -LiteralPath $sourcePath)) { Throw-SyncInstallerError -Reason "Upstream query file '$SourceRelativeFilePath' not found." -Category 'UpstreamContentMissing' -Details $sourcePath -Url $upstreamUrl } if (-not (Test-Path -LiteralPath $destinationDir) -and -not $WhatIfDryRun) { New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null } $sourceHash = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash $destinationExists = Test-Path -LiteralPath $destinationPath $destinationHash = if ($destinationExists) { (Get-FileHash -LiteralPath $destinationPath -Algorithm SHA256).Hash } else { '' } if ($destinationExists -and $sourceHash -eq $destinationHash) { Write-Host "[sync-alz-queries] No changes detected for $DestinationPathRelative." return [PSCustomObject]@{ Changed = $false DryRun = [bool]$WhatIfDryRun Action = 'NoChange' UpstreamRepo = $upstreamUrl SourceFile = (Remove-Credentials -Text $sourcePath) DestinationFile= (Remove-Credentials -Text $destinationPath) } } if ($WhatIfDryRun) { $action = if ($destinationExists) { 'would update' } else { 'would create' } Write-Host "[sync-alz-queries] DryRun: $action $DestinationPathRelative." return [PSCustomObject]@{ Changed = $true DryRun = $true Action = if ($destinationExists) { 'WouldUpdate' } else { 'WouldCreate' } UpstreamRepo = $upstreamUrl SourceFile = (Remove-Credentials -Text $sourcePath) DestinationFile= (Remove-Credentials -Text $destinationPath) } } try { Invoke-WithRetry -MaxAttempts 3 -InitialDelaySeconds 1 -MaxDelaySeconds 5 -ScriptBlock { Copy-Item -LiteralPath $sourcePath -Destination $destinationPath -Force -ErrorAction Stop $updatedHash = (Get-FileHash -LiteralPath $destinationPath -Algorithm SHA256).Hash if ($updatedHash -ne $sourceHash) { throw [System.Exception]::new('Hash mismatch after copy operation.') } $verifyResult = Invoke-WithTimeout -Command 'git' -Arguments @('hash-object', $destinationPath) -TimeoutSec 30 if ($verifyResult.ExitCode -ne 0) { throw [System.Exception]::new("git hash-object verification failed: $($verifyResult.Output)") } return $true } | Out-Null } catch { Throw-SyncInstallerError -Reason 'Failed while writing destination query file.' -Category 'WriteFailed' -Details $_.Exception.Message -Url $upstreamUrl -Remediation 'Verify file permissions and rerun the sync.' } Write-Host "[sync-alz-queries] Updated $DestinationPathRelative from upstream." return [PSCustomObject]@{ Changed = $true DryRun = $false Action = if ($destinationExists) { 'Updated' } else { 'Created' } UpstreamRepo = $upstreamUrl SourceFile = (Remove-Credentials -Text $sourcePath) DestinationFile = (Remove-Credentials -Text $destinationPath) } } finally { if ($clone -and $clone.PSObject.Properties.Name -contains 'Cleanup' -and $clone.Cleanup) { & $clone.Cleanup } } } if ($MyInvocation.InvocationName -ne '.') { Invoke-SyncAlzQueries ` -RepoRootPath $RepoRoot ` -ManifestFilePath $ManifestPath ` -SelectedToolName $ToolName ` -SourceRelativeFilePath $SourceRelativePath ` -DestinationPathRelative $DestinationRelativePath ` -WhatIfDryRun:$DryRun } |