Private/Convert-AzLocalFleetSettingsSchemaVersion.ps1
|
function Convert-AzLocalFleetSettingsSchemaVersion { <# .SYNOPSIS Upgrades fleet-settings.yml schema v1, v2, or v3 text to schema v4. .DESCRIPTION Schema v1 receives the grouped tag-filter example. Schema v2 flat tag pairs are converted to named one-tag groups, preserving their OR intent. Schema v4 adds independent before/after allowances for UpdateStartWindow. Existing comments and line endings are preserved, while recognized top-level sections are placed in the canonical v4 order: scope, updateStartWindow, reporting, itsm. Schema v4 text is returned unchanged. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Text, [Parameter(Mandatory = $false)] [string]$SourcePath = '<inline>' ) $matches = [regex]::Matches($Text, '(?m)^(\s*schemaVersion\s*:\s*)(\d+)(\s*(?:#.*)?)$') if ($matches.Count -eq 0) { $matches = [regex]::Matches($Text, '(?m)^(\s*#\s*schemaVersion\s*:\s*)(\d+)(\s*)$') if ($matches.Count -eq 0) { return [pscustomobject]@{ Migrated = $false FromVersion = $null ToVersion = 4 NewText = $Text Reason = 'NoSchemaDeclaration' } } } if ($matches.Count -ne 1) { throw "Convert-AzLocalFleetSettingsSchemaVersion: '$SourcePath' contains multiple schemaVersion declarations." } $currentVersion = [int]$matches[0].Groups[2].Value if ($currentVersion -gt 4) { throw "Convert-AzLocalFleetSettingsSchemaVersion: '$SourcePath' uses schemaVersion $currentVersion, which is newer than this module supports." } if ($currentVersion -eq 4) { return [pscustomobject]@{ Migrated = $false FromVersion = 4 ToVersion = 4 NewText = $Text Reason = 'Current' } } if ($currentVersion -notin @(1, 2, 3)) { throw "Convert-AzLocalFleetSettingsSchemaVersion: '$SourcePath' uses unsupported schemaVersion $currentVersion." } $match = $matches[0] $replacement = $match.Groups[1].Value + '4' + $match.Groups[3].Value $newText = $Text.Substring(0, $match.Index) + $replacement + $Text.Substring($match.Index + $match.Length) $newText = [regex]::Replace($newText, '(?im)(fleet settings \(schema version )\d+(\))', '${1}4${2}') $newText = [regex]::Replace($newText, '(?m)^(\s*#\s*AZLOCAL-FLEET-SETTINGS-SCHEMA-)V[123](\s*)$', '${1}V4${2}') $newline = if ($Text.Contains("`r`n")) { "`r`n" } else { "`n" } $settingsMarker = '# AZLOCAL-FLEET-SETTINGS-SCHEMA-V4' if ($currentVersion -eq 2 -and $newText -match '(?im)^\s*(?:#\s*)?clusterTagFilters\s*:') { $lines = [regex]::Split($newText, '\r?\n') $convertedLines = [System.Collections.Generic.List[string]]::new() $inTagFilters = $false $commentPrefix = '' foreach ($line in $lines) { $logicalLine = $line $lineCommentPrefix = '' if ($line -match '^(?<prefix>\s*#\s?)(?<content>.*)$') { $lineCommentPrefix = $Matches.prefix $logicalLine = $Matches.content } if ($logicalLine -match '^\s{2}clusterTagFilters\s*:') { $inTagFilters = $true $commentPrefix = $lineCommentPrefix [void]$convertedLines.Add($line) continue } if ($inTagFilters -and $logicalLine -match '^\S|^\s{2}\S') { $inTagFilters = $false } if ($inTagFilters -and $logicalLine -match '^\s{4}-\s*name\s*:\s*(.*?)\s*$') { $rawName = $Matches[1] [void]$convertedLines.Add($line) [void]$convertedLines.Add("${commentPrefix} tags:") [void]$convertedLines.Add("${commentPrefix} - name: $rawName") continue } if ($inTagFilters -and $logicalLine -match '^\s{6}value\s*:\s*(.*?)\s*$') { [void]$convertedLines.Add("${commentPrefix} value: $($Matches[1])") continue } [void]$convertedLines.Add($line) } $newText = $convertedLines -join $newline } elseif ($newText -notmatch '(?im)^\s*#?\s*clusterTagFilters\s*:') { $commentedSettings = @( $settingsMarker '# Optional global cluster admission policy. Tags in a group use AND; groups use OR.' '# Matching is exact and case-insensitive; a missing tag does not match its group.' '# Add these lines beneath your active scope block when enabling the policy:' '# clusterTagFilters:' '# - name: Production' '# tags:' '# - name: Environment' '# value: Production' '# - name: ManagedBy' '# value: CentralIT' '# - name: Test' '# tags:' '# - name: Environment' '# value: Test' ) -join $newline if ($newText.Length -gt 0 -and -not $newText.EndsWith($newline)) { $newText += $newline } $newText += $newline + $commentedSettings + $newline } if ($newText -notmatch '(?im)^\s*#?\s*updateStartWindow\s*:') { $toleranceSettings = @( '# Optional UTC allowance around every cluster UpdateStartWindow tag.' '# Values are independent, accept 0 to 60 minutes, and default to 0.' '# updateStartWindow:' '# allowBeforeMinutes: 0' '# allowAfterMinutes: 0' ) -join $newline if ($newText.Length -gt 0 -and -not $newText.EndsWith($newline)) { $newText += $newline } $newText += $newline + $toleranceSettings + $newline } # Reorder recognized top-level blocks to match fleet-settings.example.yml. # A contiguous documentation paragraph immediately before a heading moves # with that heading. A blank/comment-only separator line marks the boundary # from the preceding section, which keeps fully commented starters ordered # without attaching reporting guidance to scope (or ITSM guidance to reporting). $sectionLines = [regex]::Split($newText, '\r?\n') $sectionHeadings = [System.Collections.Generic.List[object]]::new() for ($lineIndex = 0; $lineIndex -lt $sectionLines.Count; $lineIndex++) { if ($sectionLines[$lineIndex] -match '^(?:#\s*)?(scope|updateStartWindow|reporting|itsm)\s*:') { [void]$sectionHeadings.Add([pscustomobject]@{ Name = $Matches[1] HeadingLine = $lineIndex StartLine = $lineIndex }) } } if ($sectionHeadings.Count -gt 0) { $sectionNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) for ($headingIndex = 0; $headingIndex -lt $sectionHeadings.Count; $headingIndex++) { $heading = $sectionHeadings[$headingIndex] if (-not $sectionNames.Add($heading.Name)) { throw "Convert-AzLocalFleetSettingsSchemaVersion: '$SourcePath' contains multiple '$($heading.Name)' sections." } $minimumLine = if ($headingIndex -gt 0) { $sectionHeadings[$headingIndex - 1].HeadingLine + 1 } else { 0 } $candidateStart = $heading.HeadingLine for ($precedingLine = $heading.HeadingLine - 1; $precedingLine -ge $minimumLine; $precedingLine--) { $line = $sectionLines[$precedingLine] if ($line -match '^\s*(?:#\s*)?$') { break } if ($line -match '^\s*#') { $candidateStart = $precedingLine continue } break } $heading.StartLine = $candidateStart } $sections = @{} for ($headingIndex = 0; $headingIndex -lt $sectionHeadings.Count; $headingIndex++) { $heading = $sectionHeadings[$headingIndex] $endLine = if ($headingIndex + 1 -lt $sectionHeadings.Count) { $sectionHeadings[$headingIndex + 1].StartLine - 1 } else { $sectionLines.Count - 1 } $sections[$heading.Name] = @($sectionLines[$heading.StartLine..$endLine]) -join $newline } $preambleEnd = $sectionHeadings[0].StartLine - 1 $orderedBlocks = [System.Collections.Generic.List[string]]::new() if ($preambleEnd -ge 0) { $preamble = @($sectionLines[0..$preambleEnd]) -join $newline $preamble = $preamble.TrimEnd("`r", "`n") if ($preamble) { [void]$orderedBlocks.Add($preamble) } } foreach ($sectionName in @('scope', 'updateStartWindow', 'reporting', 'itsm')) { if ($sections.ContainsKey($sectionName)) { [void]$orderedBlocks.Add(([string]$sections[$sectionName]).Trim("`r", "`n")) } } $newText = ($orderedBlocks -join ($newline + $newline)) + $newline } return [pscustomobject]@{ Migrated = $true FromVersion = $currentVersion ToVersion = 4 NewText = $newText Reason = 'Upgraded' } } |