Private/Read-AzLocalApplyUpdatesYamlCrons.ps1
|
function Read-AzLocalApplyUpdatesYamlCrons { <# .SYNOPSIS Extracts cron schedule entries from apply-updates pipeline YAML files (GitHub Actions and Azure DevOps). .DESCRIPTION Pure regex pre-scan; deliberately does NOT take a dependency on powershell-yaml. Used by Test-AzLocalApplyUpdatesScheduleCoverage. Discovery rules: - If Path is a file, scan that file. - If Path is a directory, recursively scan every *.yml / *.yaml and keep only the apply-updates pipeline, identified by its stable '# AZLOCAL-PIPELINE-ID: apply-updates' header comment (v0.8.7+). Files that lack the ID comment (pre-v0.8.7 copies) fall back to a filename match: 'apply-updates*.yml/.yaml', optionally carrying a legacy 'Step.N_' prefix - but the de-numbered sibling 'apply-updates-schedule-audit.yml' (the Step.3 audit pipeline, which has its own poll crons) is explicitly EXCLUDED so its crons are never mistaken for apply-updates windows. Platform is inferred from the parent directory name when the YAML is under .../github-actions/ or .../azure-devops/. Falls back to the Platform parameter when path-based inference is inconclusive. Parsing rules: - GitHub Actions: lines matching '- cron: "<expr>"' or "- cron: '<expr>'" under a `schedule:` map. - Azure DevOps: `cron:` keys inside a `schedules:` list. Same regex works because cron lines look identical. Cron expressions wrapped in single or double quotes are both accepted. .PARAMETER Path File or directory to scan. .PARAMETER Platform Default platform tag when path inference is inconclusive. One of 'GitHubActions', 'AzureDevOps', or 'Unknown'. .OUTPUTS PSCustomObject[] - one per discovered cron, with: File - full path RelativePath - path relative to Path (or basename if Path is a file) Platform - 'GitHubActions' | 'AzureDevOps' | 'Unknown' CronExpression - the cron string (quotes stripped) LineNumber - 1-based line in the source file .EXAMPLE Read-AzLocalApplyUpdatesYamlCrons -Path .\Automation-Pipeline-Examples #> [CmdletBinding()] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $false)] [ValidateSet('GitHubActions', 'AzureDevOps', 'Unknown')] [string]$Platform = 'Unknown' ) if (-not (Test-Path -LiteralPath $Path)) { throw "Path not found: $Path" } $item = Get-Item -LiteralPath $Path $files = if ($item.PSIsContainer) { # v0.8.7: identify the apply-updates pipeline by its stable # AZLOCAL-PIPELINE-ID header comment rather than a filename glob. The # previous 'apply-updates*.yml' wildcard now collides with the # de-numbered Step.3 audit pipeline ('apply-updates-schedule-audit.yml') # and would wrongly ingest its poll crons. Recurse all YAML, then keep # only ID == 'apply-updates'; for pre-ID copies fall back to a filename # match that explicitly excludes the schedule-audit sibling. $allYaml = @(Get-ChildItem -Path $item.FullName -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in @('.yml', '.yaml') }) $hits = New-Object System.Collections.Generic.List[System.IO.FileInfo] foreach ($yf in $allYaml) { $ymlText = $null try { $ymlText = [System.IO.File]::ReadAllText($yf.FullName, [System.Text.UTF8Encoding]::new($false)) } catch { $ymlText = $null } $ymlId = if ($ymlText) { Get-AzLocalPipelineId -Text $ymlText } else { $null } if ($ymlId) { if ($ymlId -eq 'apply-updates') { $hits.Add($yf) | Out-Null } # ID present but not apply-updates -> deliberately skipped. } elseif ($yf.Name -match '(?i)^(Step\.\d+_)?apply-updates.*\.(yml|yaml)$' -and $yf.Name -notmatch '(?i)apply-updates-schedule-audit') { # Pre-v0.8.7 copy with no ID comment: name-based fallback, # excluding the schedule-audit pipeline. $hits.Add($yf) | Out-Null } } @($hits | Sort-Object FullName -Unique) } else { @($item) } $output = New-Object System.Collections.Generic.List[PSCustomObject] foreach ($f in $files) { $inferred = $Platform if ($f.FullName -match '[\\/]github-actions[\\/]') { $inferred = 'GitHubActions' } elseif ($f.FullName -match '[\\/]azure-devops[\\/]') { $inferred = 'AzureDevOps' } $relative = if ($item.PSIsContainer) { $f.FullName.Substring($item.FullName.Length).TrimStart('\','/') } else { $f.Name } $lineNum = 0 $lines = Get-Content -LiteralPath $f.FullName -ErrorAction Stop foreach ($line in $lines) { $lineNum++ # Match cron lines in both quote styles. Allow leading dash + space for # list-style entries (- cron: '...') and bare key style (cron: '...'). if ($line -match "^\s*-?\s*cron\s*:\s*['""]([^'""]+)['""]") { $expr = $matches[1].Trim() # The character class [^'""]+ also matches whitespace runs, so # `cron: ' '` would survive with an empty capture after Trim(). # Skip those instead of feeding an empty string to a downstream # [Parameter(Mandatory)][string] binder. if ([string]::IsNullOrWhiteSpace($expr)) { continue } $output.Add([PSCustomObject]@{ File = $f.FullName RelativePath = $relative Platform = $inferred CronExpression = $expr LineNumber = $lineNum }) } } } # WARNING: Callers MUST use direct assignment ($x = func ...) and NEVER # wrap with @(func ...). The unary-comma return below preserves Object[N] # shape for any N including 0 and 1, but @() at the call site collapses # to Object[1] containing the inner array, silently producing one-row # output instead of N rows. See `docs/MODULE-REVIEW-AND-RECOMMENDATIONS.md` # Finding 1 for the v0.7.75 incident. return , $output.ToArray() } |