Public/Get-AzLocalApplyUpdatesScheduleConfig.ps1
|
function Get-AzLocalApplyUpdatesScheduleConfig { <# .SYNOPSIS Reads and validates an apply-updates-schedule.yml file (schema v1 or v2), returning a typed config object suitable for Resolve-AzLocalCurrentUpdateRing and the audit cmdlet. .DESCRIPTION Pipeline: 1. Read raw text (Get-Content -Raw). 2. Parse via the private ConvertFrom-AzLocalScheduleYaml (zero external deps). 3. Validate shape: schemaVersion must be 1 or 2; cycleWeeks 1..52; cycleAnchorISOWeek 1..53; cycleAnchorYear 2000..2100; schedule entries must each have weeksInCycle, daysOfWeek, rings (notes optional). Selectors are sanity-checked by expanding them via the same logic the resolver uses. v2 adds the optional 'allowedUpdateVersions' field (top-level fleet default + per-row override). When present, the value must be a non-empty semicolon-separated string; empty tokens after split-and-trim are rejected. v1 files that contain the field are rejected with a remediation pointing at the schema migrator. The validator parses the raw strings into $cfg.AllowedUpdateVersions [string[]] (top-level) and row.AllowedUpdateVersionsParsed [string[]] (per row) so the resolver does not re-split. Validation errors throw a single multi-line message listing every problem found (not just the first), so the operator can fix them all in one edit. .PARAMETER Path Absolute or relative path to the schedule YAML file. .OUTPUTS [PSCustomObject] - same shape produced by ConvertFrom-AzLocalScheduleYaml, plus SourcePath set to the resolved full path and (for v2) AllowedUpdateVersions parsed to an array. .EXAMPLE $cfg = Get-AzLocalApplyUpdatesScheduleConfig -Path .\.github\apply-updates-schedule.yml Resolve-AzLocalCurrentUpdateRing -Schedule $cfg #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Path ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Get-AzLocalApplyUpdatesScheduleConfig: schedule file not found: '$Path'. Generate a starter via 'New-AzLocalApplyUpdatesScheduleConfig -OutputPath <path>'." } $full = (Resolve-Path -LiteralPath $Path).Path $text = Get-Content -LiteralPath $full -Raw -ErrorAction Stop $cfg = ConvertFrom-AzLocalScheduleYaml -Text $text -SourcePath $full # ---- Validate top-level scalars --------------------------------- $errors = New-Object System.Collections.Generic.List[string] if ($null -eq $cfg.SchemaVersion -or $cfg.SchemaVersion -isnot [int]) { $errors.Add("Top-level 'schemaVersion' must be present and an integer.") | Out-Null } elseif ($cfg.SchemaVersion -ne 1 -and $cfg.SchemaVersion -ne 2) { $errors.Add("schemaVersion '$($cfg.SchemaVersion)' is not supported by this module version. Expected: 1 or 2.") | Out-Null } if ($null -eq $cfg.CycleWeeks -or $cfg.CycleWeeks -isnot [int] -or $cfg.CycleWeeks -lt 1 -or $cfg.CycleWeeks -gt 52) { $errors.Add("'cycleWeeks' must be an integer in 1..52. Got: '$($cfg.CycleWeeks)'.") | Out-Null } if ($null -eq $cfg.CycleAnchorISOWeek -or $cfg.CycleAnchorISOWeek -isnot [int] -or $cfg.CycleAnchorISOWeek -lt 1 -or $cfg.CycleAnchorISOWeek -gt 53) { $errors.Add("'cycleAnchorISOWeek' must be an integer in 1..53. Got: '$($cfg.CycleAnchorISOWeek)'.") | Out-Null } if ($null -eq $cfg.CycleAnchorYear -or $cfg.CycleAnchorYear -isnot [int] -or $cfg.CycleAnchorYear -lt 2000 -or $cfg.CycleAnchorYear -gt 2100) { $errors.Add("'cycleAnchorYear' must be an integer in 2000..2100. Got: '$($cfg.CycleAnchorYear)'.") | Out-Null } if (-not $cfg.Schedule -or @($cfg.Schedule).Count -eq 0) { $errors.Add("'schedule:' list is empty - at least one row is required.") | Out-Null } # ---- Validate top-level 'allowedUpdateVersions' (schema v2 only) - # The parser surfaces the raw string as AllowedUpdateVersionsRaw. # Schema v2 makes this field MANDATORY at the top level (the v0.7.89 # design decision). It defaults to the reserved sentinel 'Latest' # (case-insensitive) which means "no constraint - install latest # Ready update on each cluster" (the historic v0.7.88 default). # Operators replace 'Latest' with a semicolon-separated list of # explicit update names / version strings to enforce a "minimum # updates" allow-list policy fleet-wide. # # Always start the parsed array as $null so the resolver can tell # "field absent" from "field present with values". $cfg | Add-Member -NotePropertyName 'AllowedUpdateVersions' -NotePropertyValue $null -Force $topAllowRaw = $null if ($cfg.PSObject.Properties.Match('AllowedUpdateVersionsRaw').Count) { $topAllowRaw = $cfg.AllowedUpdateVersionsRaw } if ($null -ne $topAllowRaw) { if ($cfg.SchemaVersion -eq 1) { $errors.Add("Top-level 'allowedUpdateVersions' requires schemaVersion >= 2. Bump 'schemaVersion: 1' to 'schemaVersion: 2' (this file has no breaking changes - the field is additive). See https://github.com/NeilBird/Azure-Local/tree/main/AzLocal.UpdateManagement#allowedupdateversions for details.") | Out-Null } $parsedTop = Test-AzLocalAllowedUpdateVersionsString -Raw $topAllowRaw -Location "top-level 'allowedUpdateVersions'" -Errors $errors if ($null -ne $parsedTop) { $cfg.AllowedUpdateVersions = $parsedTop } } elseif ($cfg.SchemaVersion -ge 2) { # Mandatory on v2+ with no value supplied. $errors.Add("Schema v$($cfg.SchemaVersion) requires a top-level 'allowedUpdateVersions:' field. Set it to 'Latest' to keep the default 'install the latest Ready update' behaviour, or to a semicolon-separated list of explicit update names / version strings (e.g. '10.2604.0.123;10.2610.0.456') to enforce a fleet-wide allow-list. See https://github.com/NeilBird/Azure-Local/tree/main/AzLocal.UpdateManagement#allowedupdateversions for details.") | Out-Null } # ---- Validate each schedule row --------------------------------- # Cross-checks weeksInCycle / daysOfWeek tokens by attempting to # expand them. This will surface bad ranges, out-of-bounds values, # and unknown day names at load time rather than at first cron firing. if ($cfg.CycleWeeks -and ($cfg.CycleWeeks -is [int]) -and $cfg.CycleWeeks -ge 1 -and $cfg.CycleWeeks -le 52 -and $cfg.Schedule) { $i = 0 foreach ($row in @($cfg.Schedule)) { $i++ $line = if ($row.PSObject.Properties.Match('_LineNumber').Count) { " (line $($row._LineNumber))" } else { '' } foreach ($key in @('weeksInCycle', 'daysOfWeek', 'rings')) { if (-not $row.PSObject.Properties.Match($key).Count -or [string]::IsNullOrWhiteSpace([string]$row.$key)) { $errors.Add("schedule[$i]$line is missing required field '$key'.") | Out-Null } } # Selector sanity-check via a fake resolver expand. Reuse the # same regex patterns instead of dot-sourcing the resolver # functions (which are defined inside that function's scope). if ($row.weeksInCycle -and $row.weeksInCycle -ne '*') { foreach ($tok in (([string]$row.weeksInCycle) -split ',')) { $t = $tok.Trim() if ($t -match '^(\d+)-(\d+)$') { $lo = [int]$Matches[1]; $hi = [int]$Matches[2] if ($lo -lt 1 -or $hi -gt [int]$cfg.CycleWeeks -or $lo -gt $hi) { $errors.Add("schedule[$i]$line weeksInCycle range '$t' is out of 1..$($cfg.CycleWeeks).") | Out-Null } } elseif ($t -match '^\d+$') { $n = [int]$t if ($n -lt 1 -or $n -gt [int]$cfg.CycleWeeks) { $errors.Add("schedule[$i]$line weeksInCycle value '$t' is out of 1..$($cfg.CycleWeeks).") | Out-Null } } elseif ($t -ne '*') { $errors.Add("schedule[$i]$line weeksInCycle token '$t' is not recognised (expected '*', N, N-M, or comma list).") | Out-Null } } } if ($row.daysOfWeek -and $row.daysOfWeek -ne '*') { $valid = '^(sun|mon|tue|wed|thu|fri|sat|sunday|monday|tuesday|wednesday|thursday|friday|saturday|\d)$' foreach ($tok in (([string]$row.daysOfWeek) -split ',')) { $t = $tok.Trim() $rangeMatch = [regex]::Match($t, '^(.+?)-(.+)$') if ($rangeMatch.Success) { # Capture group values BEFORE running further -match # operations (which would clobber $Matches). $left = $rangeMatch.Groups[1].Value $right = $rangeMatch.Groups[2].Value if (($left -notmatch $valid) -or ($right -notmatch $valid)) { $errors.Add("schedule[$i]$line daysOfWeek range '$t' contains unknown day name.") | Out-Null } } elseif ($t -notmatch $valid) { $errors.Add("schedule[$i]$line daysOfWeek token '$t' is not recognised (expected 0-6, Sun/Mon/... names, or '*').") | Out-Null } } } # Per-row 'allowedUpdateVersions' (schema v2). The parser # passes arbitrary continuation keys through generically - # the value will be a string when present. Stamp a # uniformly-named AllowedUpdateVersionsParsed property # ($null when absent) so the resolver doesn't have to # PSObject.Match the camelCase key. $rowHasField = $row.PSObject.Properties.Match('allowedUpdateVersions').Count -gt 0 $row | Add-Member -NotePropertyName 'AllowedUpdateVersionsParsed' -NotePropertyValue $null -Force if ($rowHasField) { if ($cfg.SchemaVersion -eq 1) { $errors.Add("schedule[$i]$line uses 'allowedUpdateVersions' which requires schemaVersion >= 2. Bump 'schemaVersion: 1' to 'schemaVersion: 2'.") | Out-Null } $parsedRow = Test-AzLocalAllowedUpdateVersionsString -Raw $row.allowedUpdateVersions -Location "schedule[$i]$line 'allowedUpdateVersions'" -Errors $errors if ($null -ne $parsedRow) { $row.AllowedUpdateVersionsParsed = $parsedRow } } } } if ($errors.Count -gt 0) { $body = ($errors | ForEach-Object { " - $_" }) -join "`n" throw "Get-AzLocalApplyUpdatesScheduleConfig: $($errors.Count) validation error(s) in '$full':`n$body" } return $cfg } |