Public/Get-AzLocalFleetSettings.ps1
|
function Get-AzLocalFleetSettings { <# .SYNOPSIS Reads the optional fleet-wide pipeline settings file. .DESCRIPTION Reads config/fleet-settings.yml by default, or the path supplied by -Path or AZLOCAL_FLEET_SETTINGS_PATH. The file is optional. A missing, empty, or fully commented file returns the existing implicit Azure subscription scope used by earlier module versions. Schema versions 1 and 3 support scope.managementGroups. When one or more management-group IDs are configured, Azure Resource Graph queries that do not already specify an explicit subscription use those management groups as their query scope. Schema version 3 adds grouped scope.clusterTagFilters. Tags within one named group use AND semantics; groups use OR semantics. A group with one tag is therefore a singular alternative. The parser is deliberately limited to this small operator-owned schema so the core fleet pipelines do not require powershell-yaml. .PARAMETER Path Optional path to fleet-settings.yml. Defaults first to AZLOCAL_FLEET_SETTINGS_PATH, then to ./config/fleet-settings.yml. .OUTPUTS PSCustomObject with Path, FileFound, SchemaVersion, ScopeMode, ManagementGroups, and ClusterTagFilters properties. .EXAMPLE Get-AzLocalFleetSettings Reads the default config/fleet-settings.yml file. A fully commented starter returns ScopeMode 'ImplicitSubscriptions'. .EXAMPLE Get-AzLocalFleetSettings -Path C:\repo\config\fleet-settings.yml Reads an explicit settings file path. .NOTES Author: Neil Bird, Microsoft Module: AzLocal.UpdateManagement #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$Path ) function ConvertFrom-AzLocalFleetSettingsScalar { param( [Parameter(Mandatory = $true)][string]$RawValue, [Parameter(Mandatory = $true)][string]$PropertyName ) $value = $RawValue.Trim() if ($value.StartsWith("'")) { if ($value -notmatch "^'((?:[^']|'')*)'\s*(?:#.*)?$") { throw "Get-AzLocalFleetSettings: malformed quoted value for $PropertyName in '$Path'." } return ($Matches[1] -replace "''", "'") } if ($value.StartsWith('"')) { if ($value -notmatch '^"([^"\\]*)"\s*(?:#.*)?$') { throw "Get-AzLocalFleetSettings: malformed quoted value for $PropertyName in '$Path'. Double-quoted escape sequences are not supported; use single quotes." } return $Matches[1] } $commentIndex = $value.IndexOf(' #', [System.StringComparison]::Ordinal) if ($commentIndex -ge 0) { $value = $value.Substring(0, $commentIndex).TrimEnd() } return $value } if (-not $PSBoundParameters.ContainsKey('Path')) { if (-not [string]::IsNullOrWhiteSpace($env:AZLOCAL_FLEET_SETTINGS_PATH)) { $Path = $env:AZLOCAL_FLEET_SETTINGS_PATH } else { $Path = Join-Path -Path $PWD.Path -ChildPath 'config\fleet-settings.yml' } } $result = [PSCustomObject]@{ Path = [System.IO.Path]::GetFullPath($Path) FileFound = $false SchemaVersion = 1 ScopeMode = 'ImplicitSubscriptions' ManagementGroups = [string[]]@() ClusterTagFilters = [object[]]@() ClusterTagFilterMode = 'AnyGroup' MaxRowsPerTable = 100 MaxSummaryBytes = 900000 MaxIncidentsPerRun = 25 } if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $result } $result.FileFound = $true $lines = [System.IO.File]::ReadAllLines($result.Path, [System.Text.UTF8Encoding]::new($false)) $activeLines = @($lines | Where-Object { $trimmed = $_.Trim() $trimmed.Length -gt 0 -and -not $trimmed.StartsWith('#') }) if ($activeLines.Count -eq 0) { return $result } $schemaVersion = $null $inScope = $false $inManagementGroups = $false $inClusterTagFilters = $false $clusterTagFiltersDeclared = $false $activeSection = '' $managementGroups = [System.Collections.Generic.List[string]]::new() $clusterTagFilters = [System.Collections.Generic.List[object]]::new() $currentTagFilterGroup = $null $currentTagFilterTag = $null foreach ($line in $activeLines) { if ($line -match '^\s*schemaVersion\s*:\s*([0-9]+)\s*(?:#.*)?$') { $schemaVersion = [int]$Matches[1] continue } if ($line -match '^scope\s*:\s*(?:#.*)?$') { $activeSection = 'scope' $inScope = $true $inManagementGroups = $false $inClusterTagFilters = $false $currentTagFilterGroup = $null $currentTagFilterTag = $null continue } if ($line -match '^reporting\s*:\s*(?:#.*)?$') { $activeSection = 'reporting' $inScope = $false $inManagementGroups = $false $inClusterTagFilters = $false $currentTagFilterGroup = $null $currentTagFilterTag = $null continue } if ($line -match '^itsm\s*:\s*(?:#.*)?$') { $activeSection = 'itsm' $inScope = $false $inManagementGroups = $false $inClusterTagFilters = $false $currentTagFilterGroup = $null $currentTagFilterTag = $null continue } if ($line -match '^\S') { $activeSection = '' $inScope = $false $inManagementGroups = $false $inClusterTagFilters = $false $currentTagFilterGroup = $null $currentTagFilterTag = $null continue } if ($inScope -and $line -match '^\s{2}managementGroups\s*:\s*(?:#.*)?$') { $inManagementGroups = $true $inClusterTagFilters = $false $currentTagFilterGroup = $null $currentTagFilterTag = $null continue } if ($inScope -and $line -match '^\s{2}clusterTagFilters\s*:\s*(?:#.*)?$') { $clusterTagFiltersDeclared = $true $inManagementGroups = $false $inClusterTagFilters = $true $currentTagFilterGroup = $null $currentTagFilterTag = $null continue } if ($inScope -and $line -match '^\s{2}\S') { $inManagementGroups = $false $inClusterTagFilters = $false $currentTagFilterGroup = $null $currentTagFilterTag = $null throw "Get-AzLocalFleetSettings: unsupported or malformed active YAML at '$($result.Path)': $($line.Trim())" } if ($inManagementGroups -and $line -match '^\s{4}-\s*([^#]+?)\s*(?:#.*)?$') { $managementGroupId = $Matches[1].Trim() if ($managementGroupId.Length -ge 2 -and (($managementGroupId.StartsWith("'") -and $managementGroupId.EndsWith("'")) -or ($managementGroupId.StartsWith('"') -and $managementGroupId.EndsWith('"')))) { $managementGroupId = $managementGroupId.Substring(1, $managementGroupId.Length - 2).Trim() } if (-not [string]::IsNullOrWhiteSpace($managementGroupId) -and -not $managementGroups.Contains($managementGroupId)) { [void]$managementGroups.Add($managementGroupId) } continue } if ($inClusterTagFilters -and $line -match '^\s{4}-\s*name\s*:\s*(.*?)\s*$') { $currentTagFilterGroup = [ordered]@{ Name = ConvertFrom-AzLocalFleetSettingsScalar -RawValue $Matches[1] -PropertyName 'scope.clusterTagFilters.name' Tags = [System.Collections.Generic.List[object]]::new() } $currentTagFilterTag = $null [void]$clusterTagFilters.Add($currentTagFilterGroup) continue } if ($inClusterTagFilters -and $line -match '^\s{6}tags\s*:\s*(?:#.*)?$') { if ($null -eq $currentTagFilterGroup) { throw "Get-AzLocalFleetSettings: each scope.clusterTagFilters group must contain one name followed by tags." } continue } if ($inClusterTagFilters -and $line -match '^\s{8}-\s*name\s*:\s*(.*?)\s*$') { if ($null -eq $currentTagFilterGroup) { throw "Get-AzLocalFleetSettings: tag entries must be nested beneath a named scope.clusterTagFilters group." } $currentTagFilterTag = [ordered]@{ Name = ConvertFrom-AzLocalFleetSettingsScalar -RawValue $Matches[1] -PropertyName 'scope.clusterTagFilters.tags.name' Value = $null } [void]$currentTagFilterGroup.Tags.Add($currentTagFilterTag) continue } if ($inClusterTagFilters -and $line -match '^\s{10}value\s*:\s*(.*?)\s*$') { if ($null -eq $currentTagFilterTag -or $null -ne $currentTagFilterTag.Value) { throw "Get-AzLocalFleetSettings: each grouped tag entry must contain one name followed by one value." } $currentTagFilterTag.Value = ConvertFrom-AzLocalFleetSettingsScalar -RawValue $Matches[1] -PropertyName 'scope.clusterTagFilters.tags.value' continue } if ($activeSection -eq 'reporting' -and $line -match '^\s{2}maxRowsPerTable\s*:\s*([0-9]+)\s*(?:#.*)?$') { $result.MaxRowsPerTable = [int]$Matches[1] continue } if ($activeSection -eq 'reporting' -and $line -match '^\s{2}maxSummaryBytes\s*:\s*([0-9]+)\s*(?:#.*)?$') { $result.MaxSummaryBytes = [int]$Matches[1] continue } if ($activeSection -eq 'itsm' -and $line -match '^\s{2}maxIncidentsPerRun\s*:\s*([0-9]+)\s*(?:#.*)?$') { $result.MaxIncidentsPerRun = [int]$Matches[1] continue } throw "Get-AzLocalFleetSettings: unsupported or malformed active YAML at '$($result.Path)': $($line.Trim())" } if ($null -eq $schemaVersion) { throw "Get-AzLocalFleetSettings: active settings in '$($result.Path)' must declare schemaVersion: 1 or 3." } if ($schemaVersion -notin @(1, 3)) { throw "Get-AzLocalFleetSettings: unsupported schemaVersion '$schemaVersion' in '$($result.Path)'. This module supports schemaVersion 1 and 3." } if ($schemaVersion -eq 1 -and $clusterTagFiltersDeclared) { throw "Get-AzLocalFleetSettings: scope.clusterTagFilters requires schemaVersion: 3." } if ($clusterTagFiltersDeclared -and $clusterTagFilters.Count -eq 0) { throw "Get-AzLocalFleetSettings: scope.clusterTagFilters must contain at least one named group." } $groupNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $reservedTagNames = [System.Collections.Generic.HashSet[string]]::new( [string[]]@( 'UpdateRing', 'UpdateStartWindow', 'UpdateExclusionsWindow', 'UpdateExcluded', 'UpdateSideloaded', 'UpdateVersionInProgress', 'UpdateLastAttempt', 'UpdateRetryAttempted', 'UpdateAuthAccountId' ), [System.StringComparer]::OrdinalIgnoreCase ) foreach ($tagGroup in $clusterTagFilters) { $groupName = [string]$tagGroup.Name if ([string]::IsNullOrWhiteSpace($groupName)) { throw "Get-AzLocalFleetSettings: each scope.clusterTagFilters group requires a non-empty name." } if (-not $groupNames.Add($groupName)) { throw "Get-AzLocalFleetSettings: duplicate scope.clusterTagFilters group name '$groupName'." } if ($tagGroup.Tags.Count -eq 0) { throw "Get-AzLocalFleetSettings: scope.clusterTagFilters group '$groupName' must contain at least one tag." } $tagNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($tagFilter in $tagGroup.Tags) { $tagName = [string]$tagFilter.Name $tagValue = [string]$tagFilter.Value if ([string]::IsNullOrWhiteSpace($tagName) -or [string]::IsNullOrWhiteSpace($tagValue)) { throw "Get-AzLocalFleetSettings: each tag in group '$groupName' requires non-empty name and value properties." } if ($tagName.Length -gt 512) { throw "Get-AzLocalFleetSettings: tag name in group '$groupName' must not exceed 512 characters." } if ($tagValue.Length -gt 256) { throw "Get-AzLocalFleetSettings: tag value in group '$groupName' must not exceed 256 characters." } if ($tagName.IndexOfAny(@([char]0x0A, [char]0x0D)) -ge 0 -or $tagValue.IndexOfAny(@([char]0x0A, [char]0x0D)) -ge 0) { throw "Get-AzLocalFleetSettings: tag names and values must be single-line strings." } if (-not $tagNames.Add($tagName)) { throw "Get-AzLocalFleetSettings: duplicate tag name '$tagName' in group '$groupName'." } if ($reservedTagNames.Contains($tagName)) { throw "Get-AzLocalFleetSettings: scope.clusterTagFilters cannot use module-owned control tag '$tagName'. Use an externally governed admission tag such as Environment or ManagedBy." } } } if ($result.MaxRowsPerTable -lt 1 -or $result.MaxRowsPerTable -gt 2000) { throw "Get-AzLocalFleetSettings: reporting.maxRowsPerTable must be between 1 and 2000." } if ($result.MaxSummaryBytes -lt 10000 -or $result.MaxSummaryBytes -gt 1000000) { throw "Get-AzLocalFleetSettings: reporting.maxSummaryBytes must be between 10000 and 1000000." } if ($result.MaxIncidentsPerRun -lt 0 -or $result.MaxIncidentsPerRun -gt 1000) { throw "Get-AzLocalFleetSettings: itsm.maxIncidentsPerRun must be between 0 and 1000." } $result.SchemaVersion = $schemaVersion $result.ManagementGroups = $managementGroups.ToArray() $result.ClusterTagFilters = @($clusterTagFilters | ForEach-Object { [pscustomobject]@{ Name = $_.Name; Tags = $_.Tags.ToArray() } }) if ($result.ManagementGroups.Count -gt 0) { $result.ScopeMode = 'ManagementGroups' } return $result } |