Modules/Private/40-Execution.ps1
|
function Invoke-RangerRetry { param( [Parameter(Mandatory = $true)] [scriptblock]$ScriptBlock, [int]$RetryCount = 2, [int]$DelaySeconds = 1, [string]$Label = 'operation', [string]$Target, [string[]]$RetryOnExceptionType = @(), [switch]$Exponential, # Issue #172: optional scriptblock called with $_ to decide whether to retry. # Return $true to retry, $false to rethrow immediately without waiting. # Takes precedence over RetryOnExceptionType when provided. [scriptblock]$ShouldRetry ) $attempt = 0 do { try { return & $ScriptBlock } catch { $attempt++ $exceptionType = if ($_.Exception) { $_.Exception.GetType().FullName } else { 'UnknownException' } $shouldRetryThis = if ($ShouldRetry) { try { [bool](& $ShouldRetry $_) } catch { $true } } elseif ($RetryOnExceptionType.Count -gt 0) { $exceptionType -in $RetryOnExceptionType } else { $true } if (-not $shouldRetryThis -or $attempt -gt $RetryCount) { throw } $delay = if ($Exponential) { [math]::Pow(2, $attempt - 1) * $DelaySeconds } else { $DelaySeconds } Write-RangerLog -Level debug -Message "Retry attempt $attempt/$RetryCount for '$Label'$(if ($Target) { " on '$Target'" }) after ${exceptionType}: $($_.Exception.Message)" Add-RangerRetryDetail -Target $Target -Label $Label -Attempt $attempt -ExceptionType $exceptionType -Message $_.Exception.Message Start-Sleep -Seconds $delay } } while ($true) } function Get-RangerWinRmProbeCacheKey { param( [Parameter(Mandatory = $true)] [string]$ComputerName ) # Issue #158: cache key is target-only — Test-WSMan probes connectivity (port + WinRM service # reachability), not credential authorization. Keying on credential caused one probe per # candidate, redundant re-probes, and misleading "preflight succeeded" log lines immediately # before "Access is denied" errors. return $ComputerName.Trim().ToLowerInvariant() } function Test-RangerWinRmTarget { param( [Parameter(Mandatory = $true)] [string]$ComputerName, [PSCredential]$Credential ) if (-not $script:RangerWinRmProbeCache) { $script:RangerWinRmProbeCache = @{} } $cacheKey = Get-RangerWinRmProbeCacheKey -ComputerName $ComputerName if ($script:RangerWinRmProbeCache.ContainsKey($cacheKey)) { return $script:RangerWinRmProbeCache[$cacheKey] } $probeMessages = New-Object System.Collections.Generic.List[string] $probeOptions = @( [ordered]@{ transport = 'http'; port = 5985; useSsl = $false }, [ordered]@{ transport = 'https'; port = 5986; useSsl = $true } ) foreach ($probe in $probeOptions) { $portReachable = $true if (Test-RangerCommandAvailable -Name 'Test-NetConnection') { try { $connection = Test-NetConnection -ComputerName $ComputerName -Port $probe.port -WarningAction SilentlyContinue $portReachable = [bool]$connection.TcpTestSucceeded } catch { $portReachable = $false } if (-not $portReachable) { $probeMessages.Add("TCP $($probe.port) unreachable") continue } } if (Test-RangerCommandAvailable -Name 'Test-WSMan') { # Issue #158: do not pass credential to Test-WSMan — this is a connectivity probe # (can we reach the WinRM service?), not an authorization probe. Passing a credential # caused Test-WSMan to succeed with bad credentials and emit misleading log output. $wsmanParams = @{ ComputerName = $ComputerName Authentication = 'None' ErrorAction = 'Stop' } if ($probe.useSsl) { $wsmanParams.UseSSL = $true } try { $null = Test-WSMan @wsmanParams $state = [ordered]@{ Reachable = $true Transport = $probe.transport Port = $probe.port Message = "WinRM preflight succeeded over $($probe.transport.ToUpperInvariant())" } $script:RangerWinRmProbeCache[$cacheKey] = $state Write-RangerLog -Level debug -Message "WinRM preflight succeeded for '$ComputerName' over $($probe.transport.ToUpperInvariant())" return $state } catch { $probeMessages.Add("WSMan $($probe.transport) failed: $($_.Exception.Message)") continue } } $state = [ordered]@{ Reachable = $true Transport = $probe.transport Port = $probe.port Message = 'WinRM preflight tooling unavailable; assuming target is reachable.' } $script:RangerWinRmProbeCache[$cacheKey] = $state Write-RangerLog -Level debug -Message "WinRM preflight skipped for '$ComputerName' because Test-WSMan is unavailable" return $state } $state = [ordered]@{ Reachable = $false Transport = $null Port = $null Message = "WinRM preflight failed for '$ComputerName': $($probeMessages -join ' | ')" } $script:RangerWinRmProbeCache[$cacheKey] = $state Write-RangerLog -Level warn -Message $state.Message return $state } function Get-RangerExecutionHostContext { $computerName = $env:COMPUTERNAME $domain = $null $isDomainJoined = $null try { $computerSystem = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop $computerName = if (-not [string]::IsNullOrWhiteSpace([string]$computerSystem.Name)) { [string]$computerSystem.Name } else { $computerName } $domain = if (-not [string]::IsNullOrWhiteSpace([string]$computerSystem.Domain)) { [string]$computerSystem.Domain } else { $null } $isDomainJoined = [bool]$computerSystem.PartOfDomain } catch { } [ordered]@{ ComputerName = $computerName Domain = $domain IsDomainJoined = $isDomainJoined } } function Get-RangerRemoteCredentialCandidates { param( [PSCredential]$ClusterCredential, [PSCredential]$DomainCredential ) # Issue #165: domain credential must be tried before cluster credential. # On domain-joined Azure Local clusters the cluster/LCM account (e.g. lcm-*) is a # deployment service account that has no WinRM PSRemoting rights. The domain admin # account is the correct credential for Invoke-Command remoting. Trying cluster first # caused every authorization probe to fail with "Access is denied", burning a retry # before eventually succeeding with the domain account — or failing entirely when # retries were exhausted. $candidates = New-Object System.Collections.Generic.List[object] $seenUsers = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) if ($DomainCredential) { [void]$seenUsers.Add([string]$DomainCredential.UserName) [void]$candidates.Add([ordered]@{ Name = 'domain' Credential = $DomainCredential UserName = [string]$DomainCredential.UserName }) } if ($ClusterCredential -and $seenUsers.Add([string]$ClusterCredential.UserName)) { [void]$candidates.Add([ordered]@{ Name = 'cluster' Credential = $ClusterCredential UserName = [string]$ClusterCredential.UserName }) } if (-not $DomainCredential -and -not $ClusterCredential) { [void]$candidates.Add([ordered]@{ Name = 'current-context' Credential = $null UserName = '<current-context>' }) } return $candidates.ToArray() } function Test-RangerRemoteAuthorization { param( [Parameter(Mandatory = $true)] [string]$ComputerName, [PSCredential]$Credential, [int]$RetryCount = 1, [int]$TimeoutSeconds = 0 ) $result = @( Invoke-RangerRemoteCommand -ComputerName @($ComputerName) -Credential $Credential -RetryCount $RetryCount -TimeoutSeconds $TimeoutSeconds -ScriptBlock { $identity = try { [System.Security.Principal.WindowsIdentity]::GetCurrent().Name } catch { $env:USERNAME } [ordered]@{ computerName = $env:COMPUTERNAME identity = $identity } } ) | Select-Object -First 1 if ($null -eq $result) { throw "Authorization probe returned no data from '$ComputerName'." } return [ordered]@{ Target = $ComputerName CredentialUserName = if ($Credential) { [string]$Credential.UserName } else { '<current-context>' } RemoteComputerName = [string]$result.computerName RemoteIdentity = [string]$result.identity } } function Resolve-RangerRemoteExecutionCredential { param( [string[]]$Targets, [PSCredential]$ClusterCredential, [PSCredential]$DomainCredential, [int]$RetryCount = 1, [int]$TimeoutSeconds = 0 ) $candidateResults = New-Object System.Collections.Generic.List[object] $targets = @($Targets | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) $candidates = @(Get-RangerRemoteCredentialCandidates -ClusterCredential $ClusterCredential -DomainCredential $DomainCredential) if ($targets.Count -eq 0) { return [ordered]@{ SelectedSource = 'none' Credential = $null UserName = $null Detail = 'No remoting targets are configured.' Results = @() } } foreach ($candidate in $candidates) { $authorizationResults = New-Object System.Collections.Generic.List[object] $failures = New-Object System.Collections.Generic.List[string] foreach ($target in $targets) { try { # Issue #157: use RetryCount 0 for authorization probes — "Access is denied" is not # transient and must not be retried; the actual collection run uses $RetryCount. $authorization = Test-RangerRemoteAuthorization -ComputerName $target -Credential $candidate.Credential -RetryCount 0 -TimeoutSeconds $TimeoutSeconds [void]$authorizationResults.Add($authorization) } catch { [void]$failures.Add(("{0}: {1}" -f $target, $_.Exception.Message)) } } if ($failures.Count -eq 0) { return [ordered]@{ SelectedSource = [string]$candidate.Name Credential = $candidate.Credential UserName = [string]$candidate.UserName Detail = "Selected $($candidate.Name) remoting credential '$($candidate.UserName)' after authorization preflight succeeded on $($targets.Count) target(s)." Results = $authorizationResults.ToArray() } } [void]$candidateResults.Add([ordered]@{ Name = [string]$candidate.Name UserName = [string]$candidate.UserName Failures = $failures.ToArray() }) } $hostContext = Get-RangerExecutionHostContext $hostGuidance = if ($hostContext.IsDomainJoined -eq $false) { "Execution host '$($hostContext.ComputerName)' is not domain-joined. Prefer a qualified domain account (DOMAIN\\user or user@fqdn.domain), FQDN targets, and WinRM paths validated against actual remote command execution." } elseif ($hostContext.IsDomainJoined -eq $true) { "Execution host '$($hostContext.ComputerName)' is domain-joined to '$($hostContext.Domain)'. The failure is more likely the selected remoting credential or target-side authorization than runner domain membership." } else { "Execution host domain-join state could not be determined." } $failureSummary = @($candidateResults | ForEach-Object { "{0} credential '{1}' failed authorization: {2}" -f $_.Name, $_.UserName, ($_.Failures -join ' | ') }) -join ' ' throw "No remoting credential could authorize on all targets. $failureSummary $hostGuidance" } function Invoke-RangerRemoteCommand { param( [Parameter(Mandatory = $true)] [string[]]$ComputerName, [Parameter(Mandatory = $true)] [scriptblock]$ScriptBlock, [PSCredential]$Credential, [object[]]$ArgumentList, [int]$RetryCount = 1, [int]$TimeoutSeconds = 0, # Issue #26: transport selection — 'auto' tries WinRM, falls back to Arc on failure; # 'winrm' forces WinRM only; 'arc' forces Arc only (skips WinRM entirely) [ValidateSet('auto', 'winrm', 'arc')] [string]$TransportMode = 'auto', # Arc transport context — required when TransportMode is 'arc' or 'auto' fallback [string]$ArcResourceGroup, [string]$ArcSubscriptionId ) # ── Arc-only path ────────────────────────────────────────────────────────── if ($TransportMode -eq 'arc') { if ([string]::IsNullOrWhiteSpace($ArcResourceGroup) -or [string]::IsNullOrWhiteSpace($ArcSubscriptionId)) { throw [System.InvalidOperationException]::new( "TransportMode 'arc' requires ArcResourceGroup and ArcSubscriptionId parameters." ) } $arcResults = Invoke-RangerArcRunCommand -MachineName $ComputerName -ScriptBlock $ScriptBlock ` -ArgumentList $ArgumentList -ResourceGroupName $ArcResourceGroup ` -SubscriptionId $ArcSubscriptionId -TimeoutSeconds ($TimeoutSeconds -gt 0 ? $TimeoutSeconds : 120) # Parse JSON output — Arc Run Command returns stdout as a string; collectors # that produce structured output should write ConvertTo-Json at the end. return @($arcResults | ForEach-Object { if ($_.ExitCode -ne 0) { Write-RangerLog -Level warn -Message "Arc Run Command returned non-zero exit on '$($_.MachineName)': $($_.Error)" } if ($_.Output) { try { $_.Output | ConvertFrom-Json -Depth 100 } catch { $_.Output } } }) } $targetStates = @($ComputerName | ForEach-Object { [ordered]@{ computerName = $_ state = Test-RangerWinRmTarget -ComputerName $_ -Credential $Credential } }) $reachableStates = @($targetStates | Where-Object { $_.state.Reachable }) if ($reachableStates.Count -eq 0) { # Issue #26: when transport is 'auto' and Arc context is available, fall back to Arc if ($TransportMode -eq 'auto' -and -not [string]::IsNullOrWhiteSpace($ArcResourceGroup) -and -not [string]::IsNullOrWhiteSpace($ArcSubscriptionId) -and (Test-RangerCommandAvailable -Name 'Invoke-AzConnectedMachineRunCommand')) { Write-RangerLog -Level info -Message "WinRM unreachable on all targets — falling back to Arc Run Command transport" $arcResults = Invoke-RangerArcRunCommand -MachineName $ComputerName -ScriptBlock $ScriptBlock ` -ArgumentList $ArgumentList -ResourceGroupName $ArcResourceGroup ` -SubscriptionId $ArcSubscriptionId -TimeoutSeconds ($TimeoutSeconds -gt 0 ? $TimeoutSeconds : 120) return @($arcResults | ForEach-Object { if ($_.ExitCode -ne 0) { Write-RangerLog -Level warn -Message "Arc Run Command returned non-zero exit on '$($_.MachineName)': $($_.Error)" } if ($_.Output) { try { $_.Output | ConvertFrom-Json -Depth 100 } catch { $_.Output } } }) } $messages = @($targetStates | ForEach-Object { "$($_.computerName): $($_.state.Message)" }) throw [System.InvalidOperationException]::new("No reachable WinRM targets available. $($messages -join ' ; ')") } $unreachableTargets = @($targetStates | Where-Object { -not $_.state.Reachable } | ForEach-Object { $_.computerName }) if ($unreachableTargets.Count -gt 0) { Write-RangerLog -Level warn -Message "Skipping unreachable WinRM targets: $($unreachableTargets -join ', ')" } $targetGroups = @($reachableStates | Group-Object -Property { $_.state.Transport }) $retryBlock = { $results = New-Object System.Collections.Generic.List[object] foreach ($group in $targetGroups) { $groupTargets = @($group.Group | ForEach-Object { $_.computerName }) $invokeParams = @{ ComputerName = $groupTargets ScriptBlock = $ScriptBlock Authentication = 'Negotiate' } if ($Credential) { $invokeParams.Credential = $Credential } if ($ArgumentList) { $invokeParams.ArgumentList = $ArgumentList } if ($group.Name -eq 'https') { $invokeParams.UseSSL = $true } # Issue #113: apply per-session operation timeout when configured if ($TimeoutSeconds -gt 0) { $sessionOption = New-PSSessionOption -OperationTimeout ($TimeoutSeconds * 1000) -OpenTimeout ($TimeoutSeconds * 1000) $invokeParams.SessionOption = $sessionOption } $rangerRemoteWarnings = @() $rangerLogPath = $script:RangerLogPath $rangerCurrentLogLevel = if ($script:RangerLogLevel) { [string]$script:RangerLogLevel } else { 'info' } $rangerShouldLogWarn = $rangerCurrentLogLevel -in @('debug', 'info', 'warn') # Issue #159: catch at the innermost frame so the exception is re-thrown once rather # than propagating through Invoke-RangerRetry → Invoke-RangerRemoteCommand → # Test-RangerRemoteAuthorization, each recording a duplicate TerminatingError line in # the PowerShell transcript. try { $rangerRemoteResult = Invoke-Command @invokeParams -WarningAction SilentlyContinue -WarningVariable +rangerRemoteWarnings -ErrorAction Stop } catch { throw } foreach ($w in @($rangerRemoteWarnings)) { $warningMessage = if ($w -is [System.Management.Automation.WarningRecord]) { [string]$w.Message } else { [string]$w } if ($rangerShouldLogWarn -and -not [string]::IsNullOrWhiteSpace($warningMessage) -and $rangerLogPath) { try { Add-Content -LiteralPath $rangerLogPath -Value "[$((Get-Date).ToString('s'))][WARN] [$($groupTargets -join ',')] $warningMessage" -Encoding UTF8 -ErrorAction Stop } catch { } } } foreach ($item in @($rangerRemoteResult)) { [void]$results.Add($item) } } return $results.ToArray() }.GetNewClosure() $transientExceptions = @( 'System.Net.WebException', 'System.TimeoutException', 'System.Net.Http.HttpRequestException', 'Microsoft.Management.Infrastructure.CimException', 'System.Management.Automation.Remoting.PSRemotingTransportException' ) Invoke-RangerRetry -RetryCount $RetryCount -DelaySeconds 1 -Exponential -ScriptBlock $retryBlock -Label 'Invoke-RangerRemoteCommand' -Target ($ComputerName -join ',') -RetryOnExceptionType $transientExceptions } # Issue #26 — Arc Run Command transport # Executes a scriptblock on one or more Arc-registered machines using # Invoke-AzConnectedMachineRunCommand, which routes through the Azure control # plane rather than WinRM. Used when cluster nodes are unreachable on # ports 5985/5986 but Arc connectivity is confirmed. # # Limitations: # • Requires Az.ConnectedMachine module ≥ 1.0.0 # • Output is capped at ~32 KB per execution; collectors that return large # datasets should stream via multiple calls or use chunked output # • The scriptblock is serialised to a string and passed as -Script; closures # and $using: variables are not supported — pass data via ArgumentList # • Run Commands are asynchronous on the service side; this function polls # until the job reaches a terminal state or TimeoutSeconds is exceeded function Invoke-RangerArcRunCommand { <# .SYNOPSIS Executes a scriptblock on Arc-registered machines via Azure Run Command. .OUTPUTS Array of PSCustomObject with properties: MachineName, Output, ExitCode, Error #> param( [Parameter(Mandatory = $true)] [string[]]$MachineName, [Parameter(Mandatory = $true)] [scriptblock]$ScriptBlock, [object[]]$ArgumentList, [Parameter(Mandatory = $true)] [string]$ResourceGroupName, [Parameter(Mandatory = $true)] [string]$SubscriptionId, [string]$Location, [int]$TimeoutSeconds = 120, [int]$PollIntervalSeconds = 5 ) if (-not (Test-RangerCommandAvailable -Name 'Invoke-AzConnectedMachineRunCommand')) { throw [System.InvalidOperationException]::new( "Az.ConnectedMachine module is not available. Install it with: Install-Module Az.ConnectedMachine -Force" ) } $scriptText = $ScriptBlock.ToString() # Inject ArgumentList values as variable assignments at the top of the script. # Arc Run Command does not support -ArgumentList natively; we serialise each # argument as JSON and assign it to $args[0], $args[1] … inside the script. if ($ArgumentList -and $ArgumentList.Count -gt 0) { $argAssignments = for ($i = 0; $i -lt $ArgumentList.Count; $i++) { $serialised = $ArgumentList[$i] | ConvertTo-Json -Depth 10 -Compress "`$args_$i = '$($serialised -replace "'", "''")' | ConvertFrom-Json" } $scriptText = ($argAssignments -join "`n") + "`n" + $scriptText } $results = New-Object System.Collections.Generic.List[object] foreach ($machine in $MachineName) { Write-RangerLog -Level debug -Message "Arc Run Command: submitting to machine '$machine' in RG '$ResourceGroupName'" $runParams = @{ MachineName = $machine ResourceGroupName = $ResourceGroupName SubscriptionId = $SubscriptionId RunCommandName = "AzureLocalRanger-$(New-Guid)" Script = $scriptText ErrorAction = 'Stop' } if ($Location) { $runParams.Location = $Location } try { $job = Invoke-AzConnectedMachineRunCommand @runParams -AsJob $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { $state = $job | Get-Job -ErrorAction SilentlyContinue if (-not $state -or $state.State -in @('Completed', 'Failed', 'Stopped')) { break } Start-Sleep -Seconds $PollIntervalSeconds } if ($job.State -eq 'Running') { $job | Stop-Job -ErrorAction SilentlyContinue $job | Remove-Job -Force -ErrorAction SilentlyContinue [void]$results.Add([PSCustomObject]@{ MachineName = $machine Output = $null ExitCode = -1 Error = "Arc Run Command timed out after $TimeoutSeconds seconds" }) Write-RangerLog -Level warn -Message "Arc Run Command timed out for machine '$machine'" continue } $jobResult = $job | Receive-Job -ErrorAction SilentlyContinue $job | Remove-Job -Force -ErrorAction SilentlyContinue $output = if ($jobResult -and $jobResult.Value) { # InstanceViewOutput has a .Message property that contains stdout @($jobResult.Value | ForEach-Object { $_.Message }) -join "`n" } else { $null } [void]$results.Add([PSCustomObject]@{ MachineName = $machine Output = $output ExitCode = 0 Error = $null }) Write-RangerLog -Level debug -Message "Arc Run Command succeeded for machine '$machine'" } catch { [void]$results.Add([PSCustomObject]@{ MachineName = $machine Output = $null ExitCode = -1 Error = $_.Exception.Message }) Write-RangerLog -Level warn -Message "Arc Run Command failed for machine '$machine': $($_.Exception.Message)" } } return $results.ToArray() } function Test-RangerArcTransportAvailable { <# .SYNOPSIS Returns $true when the Arc Run Command transport can be used for the given config. .DESCRIPTION Checks: 1. Az.ConnectedMachine cmdlet is present 2. Az context is active 3. subscriptionId and resourceGroup are configured and non-placeholder #> param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Config ) if (-not (Test-RangerCommandAvailable -Name 'Invoke-AzConnectedMachineRunCommand')) { return $false } if (-not (Test-RangerCommandAvailable -Name 'Get-AzContext')) { return $false } $ctx = Get-AzContext -ErrorAction SilentlyContinue if (-not $ctx) { return $false } $subId = [string]$Config.targets.azure.subscriptionId $rg = [string]$Config.targets.azure.resourceGroup if ([string]::IsNullOrWhiteSpace($subId) -or [string]::IsNullOrWhiteSpace($rg)) { return $false } if ((Test-RangerPlaceholderValue -Value $subId -FieldName 'targets.azure.subscriptionId') -or (Test-RangerPlaceholderValue -Value $rg -FieldName 'targets.azure.resourceGroup')) { return $false } return $true } function Invoke-RangerRedfishRequest { param( [Parameter(Mandatory = $true)] [string]$Uri, [Parameter(Mandatory = $true)] [PSCredential]$Credential, [string]$Method = 'Get' ) # Issue #162: pass -Label and -Target so retry detail entries in audit-manifest.json # carry a meaningful label ('Invoke-RangerRedfishRequest') and the actual URI instead of # the generic 'operation' label and an empty target string. # Issue #172: 4xx responses are permanent — skip retry immediately so we don't waste # ~45 seconds per node retrying endpoints that simply do not exist on this iDRAC version. Invoke-RangerRetry -RetryCount 1 -Label 'Invoke-RangerRedfishRequest' -Target $Uri -ShouldRetry { param($err) if ($err.Exception -is [Microsoft.PowerShell.Commands.HttpResponseException]) { $code = [int]$err.Exception.Response.StatusCode return $code -lt 400 -or $code -ge 500 } return $true } -ScriptBlock { Invoke-RestMethod -Uri $Uri -Method $Method -Credential $Credential -SkipCertificateCheck -ContentType 'application/json' -ErrorAction Stop } } function Invoke-RangerRedfishCollection { param( [Parameter(Mandatory = $true)] [string]$CollectionUri, [Parameter(Mandatory = $true)] [string]$Host, [Parameter(Mandatory = $true)] [PSCredential]$Credential ) $results = @() $collection = Invoke-RangerRedfishRequest -Uri $CollectionUri -Credential $Credential foreach ($member in @($collection.Members)) { $path = $member.'@odata.id' if (-not $path) { continue } $uri = if ($path -match '^https?://') { $path } else { "https://$Host$path" } try { $results += Invoke-RangerRedfishRequest -Uri $uri -Credential $Credential } catch { Write-RangerLog -Level warn -Message "Redfish member retrieval failed for '$uri': $($_.Exception.Message)" } } return @($results) } function Connect-RangerAzureContext { param( $AzureCredentialSettings ) $settings = if ($AzureCredentialSettings) { ConvertTo-RangerHashtable -InputObject $AzureCredentialSettings } else { [ordered]@{ method = 'existing-context' } } $method = if ($settings.method) { [string]$settings.method } else { 'existing-context' } if (-not (Test-RangerCommandAvailable -Name 'Get-AzContext')) { return $false } $context = Get-AzContext -ErrorAction SilentlyContinue if ($context) { if ($settings.subscriptionId -and $context.Subscription -and $context.Subscription.Id -ne $settings.subscriptionId -and (Test-RangerCommandAvailable -Name 'Set-AzContext')) { try { Set-AzContext -SubscriptionId $settings.subscriptionId -ErrorAction Stop | Out-Null } catch { Write-RangerLog -Level warn -Message "Failed to switch Az context to subscription '$($settings.subscriptionId)': $($_.Exception.Message)" } } return $true } switch ($method) { 'managed-identity' { $connectParams = @{ Identity = $true; ErrorAction = 'Stop' } if ($settings.clientId) { $connectParams.AccountId = $settings.clientId } if ($settings.subscriptionId) { $connectParams.Subscription = $settings.subscriptionId } Connect-AzAccount @connectParams | Out-Null return $true } 'device-code' { $connectParams = @{ UseDeviceAuthentication = $true; ErrorAction = 'Stop' } if ($settings.tenantId) { $connectParams.Tenant = $settings.tenantId } if ($settings.subscriptionId) { $connectParams.Subscription = $settings.subscriptionId } Connect-AzAccount @connectParams | Out-Null return $true } 'service-principal' { if (-not $settings.clientSecretSecureString) { throw 'Azure service-principal authentication requires a resolved client secret.' } $credential = [PSCredential]::new([string]$settings.clientId, $settings.clientSecretSecureString) $connectParams = @{ ServicePrincipal = $true Tenant = $settings.tenantId Credential = $credential ErrorAction = 'Stop' } if ($settings.subscriptionId) { $connectParams.Subscription = $settings.subscriptionId } Connect-AzAccount @connectParams | Out-Null return $true } default { return $false } } } function Invoke-RangerAzureQuery { param( [Parameter(Mandatory = $true)] [scriptblock]$ScriptBlock, [object[]]$ArgumentList, $AzureCredentialSettings ) if (-not (Connect-RangerAzureContext -AzureCredentialSettings $AzureCredentialSettings)) { return $null } & $ScriptBlock @ArgumentList } function Test-RangerAzureCliAuthenticated { if (-not (Test-RangerCommandAvailable -Name 'az')) { return $false } & az account show --output json 2>$null | Out-Null return $LASTEXITCODE -eq 0 } function Get-RangerFixtureData { param( [string]$Path ) if ([string]::IsNullOrWhiteSpace($Path)) { return $null } $resolvedPath = Resolve-RangerPath -Path $Path if (-not (Test-Path -Path $resolvedPath)) { throw "Fixture file not found: $resolvedPath" } return Get-Content -Path $resolvedPath -Raw | ConvertFrom-Json -Depth 100 } |