Modules/Private/20-Config.ps1
|
function Get-RangerDefaultConfig { [ordered]@{ environment = [ordered]@{ name = 'prod-azlocal-01' clusterName = 'azlocal-prod-01' description = 'Primary production Azure Local instance' } targets = [ordered]@{ cluster = [ordered]@{ fqdn = 'azlocal-prod-01.contoso.com' nodes = @( 'azl-node-01.contoso.com', 'azl-node-02.contoso.com' ) } azure = [ordered]@{ subscriptionId = '00000000-0000-0000-0000-000000000000' resourceGroup = 'rg-azlocal-prod-01' tenantId = '11111111-1111-1111-1111-111111111111' } bmc = [ordered]@{ endpoints = @( [ordered]@{ host = 'idrac-node-01.contoso.com'; node = 'azl-node-01.contoso.com' }, [ordered]@{ host = 'idrac-node-02.contoso.com'; node = 'azl-node-02.contoso.com' } ) } switches = @() firewalls = @() } credentials = [ordered]@{ azure = [ordered]@{ method = 'existing-context' useAzureCliFallback = $true } cluster = [ordered]@{ username = 'CONTOSO\ranger-read' passwordRef = 'keyvault://kv-ranger/cluster-read' } domain = [ordered]@{ username = 'CONTOSO\ranger-read' passwordRef = 'keyvault://kv-ranger/domain-read' } bmc = [ordered]@{ username = 'root' passwordRef = 'keyvault://kv-ranger/idrac-root' } } domains = [ordered]@{ include = @() exclude = @() hints = [ordered]@{ fixtures = [ordered]@{} networkDeviceConfigs = @() } } output = [ordered]@{ mode = 'current-state' formats = @('html', 'markdown', 'json', 'svg') rootPath = 'C:\AzureLocalRanger' diagramFormat = 'svg' keepRawEvidence = $true } behavior = [ordered]@{ promptForMissingCredentials = $true promptForMissingRequired = $true skipUnavailableOptionalDomains = $true failOnSchemaViolation = $true logLevel = 'info' retryCount = 2 timeoutSeconds = 60 continueToRendering = $true } } } function Get-RangerAnnotatedConfigYaml { # Returns a self-documenting YAML template with [REQUIRED] markers and inline comments. # Used by New-AzureLocalRangerConfig -Format yaml (the default). @' # AzureLocalRanger configuration file # Generated by New-AzureLocalRangerConfig # # Fields marked [REQUIRED] must be updated before running Invoke-AzureLocalRanger. # Run: Invoke-AzureLocalRanger -ConfigPath <this file> # Resolution precedence: parameter > config file > interactive prompt > default > error # Docs: https://azurelocal.github.io/azurelocal-ranger environment: name: prod-azlocal-01 # [REQUIRED] Short identifier used in report filenames clusterName: azlocal-prod-01 # Friendly cluster display name for reports description: Primary production Azure Local instance targets: cluster: fqdn: azlocal-prod-01.contoso.com # [REQUIRED] FQDN of the cluster name object (CNO) nodes: - azl-node-01.contoso.com # [REQUIRED] At least one node FQDN or NetBIOS name - azl-node-02.contoso.com # Add/remove entries to match your cluster azure: subscriptionId: 00000000-0000-0000-0000-000000000000 # [REQUIRED] Azure subscription ID resourceGroup: rg-azlocal-prod-01 # [REQUIRED] Resource group of the Arc-enabled HCI resource tenantId: 11111111-1111-1111-1111-111111111111 # [REQUIRED] Azure AD / Entra tenant ID bmc: endpoints: - host: idrac-node-01.contoso.com # BMC hostname or IP for first node (optional) node: azl-node-01.contoso.com - host: idrac-node-02.contoso.com # BMC hostname or IP for second node (optional) node: azl-node-02.contoso.com switches: [] # Add network switch targets here (optional) firewalls: [] # Add firewall targets here (optional) credentials: azure: method: existing-context # Options: existing-context | device-code | service-principal | managed-identity | azure-cli useAzureCliFallback: true # Fall back to az cli token if Connect-AzAccount context is missing cluster: username: 'CONTOSO\ranger-read' # [REQUIRED] Account with WinRM / WS-Man read access to cluster nodes passwordRef: keyvault://kv-ranger/cluster-read # Vault reference, plain password, or leave blank to be prompted domain: username: 'CONTOSO\ranger-read' # Account for AD queries; leave blank to reuse cluster credential passwordRef: keyvault://kv-ranger/domain-read bmc: username: root # BMC credential (iDRAC / iLO); only needed if BMC targets are set passwordRef: keyvault://kv-ranger/idrac-root domains: include: [] # Limit collection to these domain FQDNs (empty = auto-detect from Arc/AD) exclude: [] # Skip these domain FQDNs during collection hints: fixtures: {} # Static override values supplied directly (rarely needed) networkDeviceConfigs: [] # Paths to switch / firewall configuration files output: mode: current-state # Options: current-state | compliance | drift formats: # Report formats to generate - html - markdown - json - svg rootPath: 'C:\AzureLocalRanger' # Output directory; each run creates a dated sub-folder diagramFormat: svg # Options: svg | png keepRawEvidence: true # Keep raw JSON evidence alongside reports behavior: promptForMissingCredentials: true # Prompt interactively when a credential cannot be resolved promptForMissingRequired: true # Prompt interactively for missing required structural values skipUnavailableOptionalDomains: true # Skip optional collectors (BMC, switches) if unreachable failOnSchemaViolation: true # Abort if config fails schema validation logLevel: info # Options: debug | info | warning | error retryCount: 2 # WinRM retry attempts per command timeoutSeconds: 60 # WinRM operation timeout in seconds continueToRendering: true # Generate reports even when some collectors partially fail '@ } function ConvertTo-RangerYaml { param( [Parameter(Mandatory = $true)] $InputObject, [int]$Indent = 0 ) $prefix = ' ' * $Indent $lines = New-Object System.Collections.Generic.List[string] if ($InputObject -is [System.Collections.IDictionary]) { foreach ($key in $InputObject.Keys) { $value = $InputObject[$key] if ($value -is [System.Collections.IDictionary]) { $lines.Add(('{0}{1}:' -f $prefix, $key)) foreach ($line in (ConvertTo-RangerYaml -InputObject $value -Indent ($Indent + 2))) { $lines.Add($line) } continue } if ($value -is [System.Collections.IEnumerable] -and $value -isnot [string]) { $lines.Add(('{0}{1}:' -f $prefix, $key)) foreach ($item in $value) { if ($item -is [System.Collections.IDictionary]) { $first = $true foreach ($itemKey in $item.Keys) { $itemValue = $item[$itemKey] if ($itemValue -is [System.Collections.IDictionary] -or ($itemValue -is [System.Collections.IEnumerable] -and $itemValue -isnot [string])) { $lines.Add((if ($first) { ('{0} - {1}:' -f $prefix, $itemKey) } else { ('{0} {1}:' -f $prefix, $itemKey) })) foreach ($childLine in (ConvertTo-RangerYaml -InputObject $itemValue -Indent ($Indent + 6))) { $lines.Add($childLine) } } else { $scalar = ConvertTo-RangerYamlScalar -Value $itemValue $lines.Add((if ($first) { ('{0} - {1}: {2}' -f $prefix, $itemKey, $scalar) } else { ('{0} {1}: {2}' -f $prefix, $itemKey, $scalar) })) } $first = $false } } else { $lines.Add("$prefix - $(ConvertTo-RangerYamlScalar -Value $item)") } } continue } $lines.Add(('{0}{1}: {2}' -f $prefix, $key, (ConvertTo-RangerYamlScalar -Value $value))) } return $lines } if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { foreach ($item in $InputObject) { $lines.Add("$prefix- $(ConvertTo-RangerYamlScalar -Value $item)") } return $lines } return @("$prefix$(ConvertTo-RangerYamlScalar -Value $InputObject)") } function ConvertTo-RangerYamlScalar { param( [AllowNull()] $Value ) if ($null -eq $Value) { return 'null' } if ($Value -is [bool]) { return $Value.ToString().ToLowerInvariant() } if ($Value -is [int] -or $Value -is [long] -or $Value -is [double]) { return [string]$Value } $text = [string]$Value if ($text -match '^[A-Za-z0-9._/-]+$') { return $text } return "'$($text -replace '''', '''''')'" } function Merge-RangerConfiguration { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$BaseConfig, [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$OverrideConfig ) $result = ConvertTo-RangerHashtable -InputObject $BaseConfig foreach ($key in $OverrideConfig.Keys) { $overrideValue = $OverrideConfig[$key] if ($result.Contains($key) -and $result[$key] -is [System.Collections.IDictionary] -and $overrideValue -is [System.Collections.IDictionary]) { $result[$key] = Merge-RangerConfiguration -BaseConfig $result[$key] -OverrideConfig $overrideValue continue } $result[$key] = ConvertTo-RangerHashtable -InputObject $overrideValue } $result } function Normalize-RangerConfiguration { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Config ) $Config.targets.cluster.nodes = @($Config.targets.cluster.nodes | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}') }) $Config.targets.bmc.endpoints = @( foreach ($endpoint in @($Config.targets.bmc.endpoints)) { if ($null -eq $endpoint) { continue } if ($endpoint -is [string]) { $host = [string]$endpoint if ([string]::IsNullOrWhiteSpace($host) -or $host -in @('[]', '{}')) { continue } [ordered]@{ host = $host node = $null } continue } $host = if ($endpoint -is [System.Collections.IDictionary]) { $endpoint['host'] } else { $endpoint.host } $node = if ($endpoint -is [System.Collections.IDictionary]) { $endpoint['node'] } else { $endpoint.node } if ([string]::IsNullOrWhiteSpace([string]$host)) { continue } [ordered]@{ host = [string]$host node = if ([string]::IsNullOrWhiteSpace([string]$node)) { $null } else { [string]$node } } } ) $Config.targets.switches = @($Config.targets.switches | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}') }) $Config.targets.firewalls = @($Config.targets.firewalls | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}') }) $Config.domains.include = @($Config.domains.include | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}') }) $Config.domains.exclude = @($Config.domains.exclude | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}') }) $Config.domains.hints.networkDeviceConfigs = @( foreach ($hint in @($Config.domains.hints.networkDeviceConfigs)) { if ($null -eq $hint) { continue } if ($hint -is [string]) { $path = [string]$hint if ([string]::IsNullOrWhiteSpace($path) -or $path -in @('[]', '{}')) { continue } [ordered]@{ path = $path } continue } $path = if ($hint -is [System.Collections.IDictionary]) { $hint['path'] } else { $hint.path } if ([string]::IsNullOrWhiteSpace([string]$path)) { continue } [ordered]@{ path = [string]$path vendor = if ($hint -is [System.Collections.IDictionary]) { $hint['vendor'] } else { $hint.vendor } role = if ($hint -is [System.Collections.IDictionary]) { $hint['role'] } else { $hint.role } } } ) $Config.output.formats = @($Config.output.formats | Where-Object { $null -ne $_ -and -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}') }) return $Config } function Import-RangerConfiguration { param( [string]$ConfigPath, $ConfigObject ) if ($ConfigObject) { return Normalize-RangerConfiguration -Config (Merge-RangerConfiguration -BaseConfig (Get-RangerDefaultConfig) -OverrideConfig (ConvertTo-RangerHashtable -InputObject $ConfigObject)) } if (-not $ConfigPath) { throw 'Either ConfigPath or ConfigObject must be supplied.' } $resolvedConfigPath = Resolve-RangerPath -Path $ConfigPath if (-not (Test-Path -Path $resolvedConfigPath)) { throw "Configuration file not found: $resolvedConfigPath" } $extension = [System.IO.Path]::GetExtension($resolvedConfigPath).ToLowerInvariant() switch ($extension) { '.json' { $loaded = Get-Content -Path $resolvedConfigPath -Raw | ConvertFrom-Json -Depth 100 } '.psd1' { $loaded = Import-PowerShellDataFile -Path $resolvedConfigPath } '.yml' { $loaded = Import-RangerYamlFile -Path $resolvedConfigPath } '.yaml' { $loaded = Import-RangerYamlFile -Path $resolvedConfigPath } default { throw "Unsupported configuration format: $extension" } } return Normalize-RangerConfiguration -Config (Merge-RangerConfiguration -BaseConfig (Get-RangerDefaultConfig) -OverrideConfig (ConvertTo-RangerHashtable -InputObject $loaded)) } function Set-RangerStructuralOverrides { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Config, [hashtable]$StructuralOverrides ) if (-not $StructuralOverrides -or $StructuralOverrides.Count -eq 0) { return $Config } if ($StructuralOverrides.ContainsKey('ClusterFqdn') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['ClusterFqdn'])) { $Config.targets.cluster.fqdn = $StructuralOverrides['ClusterFqdn'] } if ($StructuralOverrides.ContainsKey('ClusterNodes') -and @($StructuralOverrides['ClusterNodes']).Count -gt 0) { $Config.targets.cluster.nodes = @($StructuralOverrides['ClusterNodes']) } if ($StructuralOverrides.ContainsKey('EnvironmentName') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['EnvironmentName'])) { $Config.environment.name = $StructuralOverrides['EnvironmentName'] } if ($StructuralOverrides.ContainsKey('SubscriptionId') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['SubscriptionId'])) { $Config.targets.azure.subscriptionId = $StructuralOverrides['SubscriptionId'] } if ($StructuralOverrides.ContainsKey('TenantId') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['TenantId'])) { $Config.targets.azure.tenantId = $StructuralOverrides['TenantId'] } if ($StructuralOverrides.ContainsKey('ResourceGroup') -and -not [string]::IsNullOrWhiteSpace($StructuralOverrides['ResourceGroup'])) { $Config.targets.azure.resourceGroup = $StructuralOverrides['ResourceGroup'] } return $Config } function Test-RangerInteractivePromptAvailable { if (Get-Module -Name Pester -ErrorAction SilentlyContinue) { return $false } if (Get-Variable -Name PesterPreference -Scope Global -ErrorAction SilentlyContinue) { return $false } try { return [Environment]::UserInteractive -and -not [Console]::IsInputRedirected } catch { return [Environment]::UserInteractive } } function Test-RangerPlaceholderValue { param( [AllowNull()] $Value, [Parameter(Mandatory = $true)] [string]$FieldName ) if ($null -eq $Value) { return $true } $text = [string]$Value if ([string]::IsNullOrWhiteSpace($text)) { return $true } switch ($FieldName) { 'environment.name' { return $text -eq 'prod-azlocal-01' } 'targets.cluster.fqdn' { return $text -eq 'azlocal-prod-01.contoso.com' } 'targets.cluster.node' { return $text -match '^azl-node-0[1-9]\.contoso\.com$' } 'targets.azure.subscriptionId' { return $text -eq '00000000-0000-0000-0000-000000000000' } 'targets.azure.tenantId' { return $text -eq '11111111-1111-1111-1111-111111111111' } 'targets.azure.resourceGroup' { return $text -eq 'rg-azlocal-prod-01' } default { return $false } } } function Get-RangerMissingRequiredInputs { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Config, [object[]]$SelectedCollectors ) $collectors = if ($SelectedCollectors) { @($SelectedCollectors) } else { Resolve-RangerSelectedCollectors -Config $Config } $fixtureMap = if ($Config.domains -and $Config.domains.hints -and $Config.domains.hints.fixtures) { $Config.domains.hints.fixtures } else { $null } $fixtureMode = $false if ($fixtureMap -is [System.Collections.IDictionary] -and $collectors.Count -gt 0) { $fixtureMode = @($collectors | Where-Object { -not $fixtureMap.Contains($_.Id) -or [string]::IsNullOrWhiteSpace([string]$fixtureMap[$_.Id]) }).Count -eq 0 } if ($fixtureMode) { return @() } $requiresAzure = @($collectors | Where-Object { 'azure' -in @($_.RequiredTargets) }).Count -gt 0 $missing = New-Object System.Collections.ArrayList if (Test-RangerPlaceholderValue -Value $Config.environment.name -FieldName 'environment.name') { [void]$missing.Add([ordered]@{ Path = 'environment.name'; Prompt = 'Environment name'; Example = 'tplabs-prod-01'; Description = 'Short identifier used in output file names' }) } $clusterNodes = @($Config.targets.cluster.nodes | Where-Object { -not (Test-RangerPlaceholderValue -Value $_ -FieldName 'targets.cluster.node') }) $hasClusterFqdn = -not (Test-RangerPlaceholderValue -Value $Config.targets.cluster.fqdn -FieldName 'targets.cluster.fqdn') if (-not $hasClusterFqdn -and $clusterNodes.Count -eq 0) { [void]$missing.Add([ordered]@{ Path = 'targets.cluster.fqdn'; Prompt = 'Cluster FQDN'; Example = 'mycluster.contoso.com'; Description = 'Cluster name object FQDN or first reachable node FQDN' }) } if ($requiresAzure) { if (Test-RangerPlaceholderValue -Value $Config.targets.azure.subscriptionId -FieldName 'targets.azure.subscriptionId') { [void]$missing.Add([ordered]@{ Path = 'targets.azure.subscriptionId'; Prompt = 'Azure subscription ID'; Example = '22222222-2222-2222-2222-222222222222'; Description = 'Azure subscription that contains the Arc-enabled HCI resource' }) } if (Test-RangerPlaceholderValue -Value $Config.targets.azure.tenantId -FieldName 'targets.azure.tenantId') { [void]$missing.Add([ordered]@{ Path = 'targets.azure.tenantId'; Prompt = 'Azure tenant ID'; Example = '33333333-3333-3333-3333-333333333333'; Description = 'Microsoft Entra tenant ID' }) } if (Test-RangerPlaceholderValue -Value $Config.targets.azure.resourceGroup -FieldName 'targets.azure.resourceGroup') { [void]$missing.Add([ordered]@{ Path = 'targets.azure.resourceGroup'; Prompt = 'Azure resource group'; Example = 'rg-azlocal-prod-01'; Description = 'Resource group that contains the Arc-enabled HCI cluster' }) } } return @($missing) } function Set-RangerConfigValueByPath { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Config, [Parameter(Mandatory = $true)] [string]$Path, [AllowNull()] $Value ) switch ($Path) { 'environment.name' { $Config.environment.name = $Value } 'targets.cluster.fqdn' { $Config.targets.cluster.fqdn = $Value } 'targets.azure.subscriptionId' { $Config.targets.azure.subscriptionId = $Value } 'targets.azure.tenantId' { $Config.targets.azure.tenantId = $Value } 'targets.azure.resourceGroup' { $Config.targets.azure.resourceGroup = $Value } } } function Invoke-RangerInteractiveInput { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Config ) if (-not [bool]$Config.behavior.promptForMissingRequired) { return $Config } if (-not (Test-RangerInteractivePromptAvailable)) { return $Config } foreach ($missing in @(Get-RangerMissingRequiredInputs -Config $Config)) { $prompt = "[Ranger] $($missing.Prompt) is required.`nEnter $($missing.Prompt) (e.g., $($missing.Example)):" $value = Read-Host -Prompt $prompt if (-not [string]::IsNullOrWhiteSpace($value)) { Set-RangerConfigValueByPath -Config $Config -Path $missing.Path -Value $value.Trim() } } return $Config } function Import-RangerYamlFile { param( [Parameter(Mandatory = $true)] [string]$Path ) if (Test-RangerCommandAvailable -Name 'ConvertFrom-Yaml') { return Get-Content -Path $Path -Raw | ConvertFrom-Yaml } if (Get-Module -ListAvailable -Name 'powershell-yaml') { Import-Module powershell-yaml -ErrorAction Stop return Get-Content -Path $Path -Raw | ConvertFrom-Yaml } throw 'YAML parsing requires the powershell-yaml module or a runtime that provides ConvertFrom-Yaml.' } function Resolve-RangerCanonicalDomainName { param( [Parameter(Mandatory = $true)] [string]$Name ) $aliases = Get-RangerDomainAliases $key = $Name.Trim().ToLowerInvariant() if ($aliases.Keys -contains $key) { return $aliases[$key] } return $key } function Resolve-RangerSelectedCollectors { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Config ) $definitions = Get-RangerCollectorDefinitions $include = @($Config.domains.include | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { Resolve-RangerCanonicalDomainName -Name $_ }) $exclude = @($Config.domains.exclude | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { Resolve-RangerCanonicalDomainName -Name $_ }) $selected = New-Object System.Collections.ArrayList foreach ($definition in $definitions.Values) { $covers = @($definition.Covers | ForEach-Object { Resolve-RangerCanonicalDomainName -Name $_ }) $isIncluded = $include.Count -eq 0 -or @($covers | Where-Object { $_ -in $include }).Count -gt 0 $isExcluded = @($covers | Where-Object { $_ -in $exclude }).Count -gt 0 if ($isIncluded -and -not $isExcluded) { [void]$selected.Add($definition) } } return @($selected) } function Test-RangerConfiguration { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Config, [switch]$PassThru ) $errors = New-Object System.Collections.Generic.List[string] $warnings = New-Object System.Collections.Generic.List[string] $include = @($Config.domains.include | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { Resolve-RangerCanonicalDomainName -Name $_ }) $exclude = @($Config.domains.exclude | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { Resolve-RangerCanonicalDomainName -Name $_ }) $selectedCollectors = Resolve-RangerSelectedCollectors -Config $Config foreach ($domain in $include) { if ($domain -in $exclude) { $errors.Add("Domain '$domain' cannot appear in both include and exclude.") } } if ($Config.output.mode -notin @('current-state', 'as-built')) { $errors.Add("Output mode '$($Config.output.mode)' is not supported.") } $supportedFormats = @('html', 'markdown', 'md', 'svg', 'drawio', 'xml', 'json', 'docx', 'xlsx', 'pdf') foreach ($format in @($Config.output.formats)) { if ($format -notin $supportedFormats) { $errors.Add("Output format '$format' is not supported.") } } $validLogLevels = @('debug', 'info', 'warn', 'warning', 'error', 'verbose') if ([string]::IsNullOrWhiteSpace($Config.behavior.logLevel) -or $Config.behavior.logLevel -notin $validLogLevels) { $errors.Add("behavior.logLevel '$($Config.behavior.logLevel)' is invalid. Valid values: debug, info, warn, warning, error, verbose.") } foreach ($missing in @(Get-RangerMissingRequiredInputs -Config $Config -SelectedCollectors $selectedCollectors)) { $errors.Add("Required input '$($missing.Path)' is missing or still set to its scaffold placeholder value.") } $azureSettings = Resolve-RangerAzureCredentialSettings -Config $Config -SkipSecretResolution $supportedAzureMethods = @('existing-context', 'managed-identity', 'device-code', 'service-principal', 'azure-cli') if ($azureSettings.method -notin $supportedAzureMethods) { $errors.Add("Azure credential method '$($azureSettings.method)' is not supported.") } if ($azureSettings.method -eq 'service-principal') { if ([string]::IsNullOrWhiteSpace($azureSettings.clientId)) { $errors.Add('Azure service-principal authentication requires credentials.azure.clientId.') } if ([string]::IsNullOrWhiteSpace($azureSettings.tenantId)) { $errors.Add('Azure service-principal authentication requires a tenantId in targets.azure or credentials.azure.') } if ([string]::IsNullOrWhiteSpace($Config.credentials.azure.clientSecret) -and [string]::IsNullOrWhiteSpace($Config.credentials.azure.clientSecretRef)) { $errors.Add('Azure service-principal authentication requires credentials.azure.clientSecret or credentials.azure.clientSecretRef.') } } foreach ($credentialName in @('cluster', 'domain', 'bmc', 'firewall', 'switch')) { $credentialBlock = $Config.credentials[$credentialName] if ($credentialBlock -and $credentialBlock.passwordRef) { try { ConvertFrom-RangerKeyVaultUri -Uri $credentialBlock.passwordRef | Out-Null } catch { $errors.Add("Credential '$credentialName' has an invalid Key Vault reference: $($_.Exception.Message)") } } } foreach ($collector in $selectedCollectors) { foreach ($requiredTarget in $collector.RequiredTargets) { if (-not (Test-RangerTargetConfigured -Config $Config -TargetName $requiredTarget)) { if ($collector.Class -eq 'core') { $errors.Add("Collector '$($collector.Id)' requires target '$requiredTarget'.") } else { $warnings.Add("Collector '$($collector.Id)' will be skipped because target '$requiredTarget' is not configured.") } } } } if ($azureSettings.method -eq 'azure-cli' -and -not [bool]$Config.credentials.azure.useAzureCliFallback) { $warnings.Add('Azure CLI authentication was selected without CLI fallback enabled; Azure resource enumeration may be incomplete.') } $result = [ordered]@{ IsValid = $errors.Count -eq 0 Errors = @($errors) Warnings = @($warnings) } if ($PassThru) { return $result } if (-not $result.IsValid) { throw ($result.Errors -join [Environment]::NewLine) } } function Test-RangerTargetConfigured { param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Config, [Parameter(Mandatory = $true)] [string]$TargetName ) switch ($TargetName) { 'cluster' { $clusterTarget = $Config.targets.cluster if ($null -eq $clusterTarget) { return $false } return -not [string]::IsNullOrWhiteSpace($clusterTarget.fqdn) -or @($clusterTarget.nodes | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -gt 0 } 'azure' { return -not [string]::IsNullOrWhiteSpace($Config.targets.azure.subscriptionId) -or -not [string]::IsNullOrWhiteSpace($Config.targets.azure.resourceGroup) } 'bmc' { return @($Config.targets.bmc.endpoints | Where-Object { if ($null -eq $_) { return $false } if ($_ -is [string]) { return -not [string]::IsNullOrWhiteSpace([string]$_) -and [string]$_ -notin @('[]', '{}') } $host = if ($_ -is [System.Collections.IDictionary]) { $_['host'] } else { $_.host } return -not [string]::IsNullOrWhiteSpace([string]$host) }).Count -gt 0 } default { return $false } } } |