Modules/Public/10-Commands.ps1

function Invoke-AzureLocalRanger {
    <#
    .SYNOPSIS
        Runs the AzureLocalRanger discovery and reporting pipeline against an Azure Local cluster.

    .DESCRIPTION
        Invoke-AzureLocalRanger is the primary entry point for the AzureLocalRanger module.
        It loads configuration, resolves credentials, executes all enabled collectors against
        the target cluster and its nodes, then renders the requested output report formats
        (HTML, Markdown, DOCX, XLSX, PDF, and SVG diagrams).

        Structural override parameters (ClusterFqdn, ClusterNodes, etc.) take precedence over
        values in the configuration file, making it convenient to run one-off assessments without
        modifying config files.

    .PARAMETER ConfigPath
        Path to a Ranger YAML or JSON configuration file. Use New-AzureLocalRangerConfig to
        generate a starter file. When omitted, Ranger applies built-in defaults only.

    .PARAMETER ConfigObject
        An in-memory hashtable or PSCustomObject representing configuration. Merged over defaults.
        Useful for pipeline scenarios where config is constructed programmatically.

    .PARAMETER OutputPath
        Directory to write the report package. Defaults to config output.rootPath
        (C:\AzureLocalRanger) with a dated sub-folder per run.

    .PARAMETER IncludeDomain
        Limit collection to the specified domain FQDNs. Overrides config domains.include.

    .PARAMETER ExcludeDomain
        Skip the specified domain FQDNs during collection. Overrides config domains.exclude.

    .PARAMETER ClusterCredential
        PSCredential used to connect to cluster nodes via WinRM. Overrides config
        credentials.cluster.

    .PARAMETER DomainCredential
        PSCredential used for Active Directory / domain queries. Overrides config
        credentials.domain.

    .PARAMETER BmcCredential
        PSCredential used for BMC (iDRAC / iLO) access. Overrides config credentials.bmc.

    .PARAMETER NoRender
        Collect data but skip report rendering. The raw manifest JSON is still written.

    .PARAMETER ClusterFqdn
        FQDN or NetBIOS name of the cluster name object (CNO). Overrides
        config targets.cluster.fqdn.

    .PARAMETER ClusterNodes
        List of node FQDNs or NetBIOS names to target. Overrides config
        targets.cluster.nodes.

    .PARAMETER EnvironmentName
        Short identifier for the environment (used in report filenames). Overrides
        config environment.name.

    .PARAMETER SubscriptionId
        Azure subscription ID containing the Arc-enabled HCI resource. Overrides
        config targets.azure.subscriptionId.

    .PARAMETER TenantId
        Azure Entra tenant ID. Overrides config targets.azure.tenantId.

    .PARAMETER ResourceGroup
        Azure resource group name that contains the Arc-enabled HCI cluster resource.
        Overrides config targets.azure.resourceGroup.

    .OUTPUTS
        System.Collections.Hashtable — the completed run manifest. Also writes report files
        to the output directory.

    .EXAMPLE
        # Run using a config file
        Invoke-AzureLocalRanger -ConfigPath .\ranger.yml

    .EXAMPLE
        # Quick one-off run with inline overrides — no config file needed
        Invoke-AzureLocalRanger `
            -ClusterFqdn azlocal-prod.contoso.com `
            -ClusterNodes azl-n01.contoso.com,azl-n02.contoso.com `
            -ClusterCredential (Get-Credential) `
            -SubscriptionId '<guid>' `
            -ResourceGroup rg-azlocal-prod

    .EXAMPLE
        # Collect only; skip report rendering
        Invoke-AzureLocalRanger -ConfigPath .\ranger.yml -NoRender

    .LINK
        https://azurelocal.github.io/azurelocal-ranger/prerequisites/

    .LINK
        https://azurelocal.github.io/azurelocal-ranger/operator/command-reference/
    #>

    [CmdletBinding()]
    param(
        [string]$ConfigPath,
        $ConfigObject,
        [string]$OutputPath,
        [string[]]$IncludeDomain,
        [string[]]$ExcludeDomain,
        [PSCredential]$ClusterCredential,
        [PSCredential]$DomainCredential,
        [PSCredential]$BmcCredential,
        [switch]$NoRender,

        # Issue #115: structural overrides — any of these win over the config file value
        [string]$ClusterFqdn,
        [string[]]$ClusterNodes,
        [string]$EnvironmentName,
        [string]$SubscriptionId,
        [string]$TenantId,
        [string]$ResourceGroup
    )

    $credentialOverrides = @{
        cluster = $ClusterCredential
        domain  = $DomainCredential
        bmc     = $BmcCredential
    }

    $structuralOverrides = @{}
    if ($PSBoundParameters.ContainsKey('ClusterFqdn'))     { $structuralOverrides['ClusterFqdn']     = $ClusterFqdn }
    if ($PSBoundParameters.ContainsKey('ClusterNodes'))    { $structuralOverrides['ClusterNodes']    = $ClusterNodes }
    if ($PSBoundParameters.ContainsKey('EnvironmentName')) { $structuralOverrides['EnvironmentName'] = $EnvironmentName }
    if ($PSBoundParameters.ContainsKey('SubscriptionId'))  { $structuralOverrides['SubscriptionId']  = $SubscriptionId }
    if ($PSBoundParameters.ContainsKey('TenantId'))        { $structuralOverrides['TenantId']        = $TenantId }
    if ($PSBoundParameters.ContainsKey('ResourceGroup'))   { $structuralOverrides['ResourceGroup']   = $ResourceGroup }

    Invoke-RangerDiscoveryRuntime -ConfigPath $ConfigPath -ConfigObject $ConfigObject -OutputPath $OutputPath -CredentialOverrides $credentialOverrides -IncludeDomains $IncludeDomain -ExcludeDomains $ExcludeDomain -NoRender:$NoRender -StructuralOverrides $structuralOverrides -AllowInteractiveInput
}

function New-AzureLocalRangerConfig {
    <#
    .SYNOPSIS
        Generates a new, self-documenting AzureLocalRanger configuration file.

    .DESCRIPTION
        New-AzureLocalRangerConfig writes a starter configuration to disk in YAML (default)
        or JSON format. The YAML output includes inline comments that describe every key and
        mark fields that must be filled in before running Invoke-AzureLocalRanger.

    .PARAMETER Path
        Destination path for the configuration file, e.g. C:\ranger\ranger.yml.
        The parent directory is created if it does not already exist.

    .PARAMETER Format
        Output format. Accepted values: yaml (default), json.

    .PARAMETER Force
        Overwrite an existing file at Path. Without this switch the command will throw
        if the file already exists.

    .OUTPUTS
        System.IO.FileInfo — the newly created configuration file.

    .EXAMPLE
        New-AzureLocalRangerConfig -Path C:\ranger\ranger.yml

    .EXAMPLE
        New-AzureLocalRangerConfig -Path C:\ranger\ranger.json -Format json -Force
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path,

        [ValidateSet('yaml', 'json')]
        [string]$Format = 'yaml',

        [switch]$Force
    )

    $resolvedPath = Resolve-RangerPath -Path $Path
    if ((Test-Path -Path $resolvedPath) -and -not $Force) {
        throw "The configuration file already exists: $resolvedPath"
    }

    New-Item -ItemType Directory -Path (Split-Path -Parent $resolvedPath) -Force | Out-Null
    if ($Format -eq 'json') {
        Get-RangerDefaultConfig | ConvertTo-Json -Depth 50 | Set-Content -Path $resolvedPath -Encoding UTF8
    }
    else {
        Get-RangerAnnotatedConfigYaml | Set-Content -Path $resolvedPath -Encoding UTF8
    }

    Get-Item -Path $resolvedPath
}

function Export-AzureLocalRangerReport {
    <#
    .SYNOPSIS
        Re-renders report files from an existing Ranger run manifest.

    .DESCRIPTION
        Export-AzureLocalRangerReport reads a previously written ranger-manifest.json file
        and regenerates the requested output formats without re-running any collectors.
        Useful for producing additional formats or updating report templates after a run.

    .PARAMETER ManifestPath
        Path to the ranger-manifest.json file from a prior Invoke-AzureLocalRanger run.

    .PARAMETER OutputPath
        Directory to write the re-rendered reports. Defaults to the same directory as
        ManifestPath.

    .PARAMETER Formats
        Report formats to generate. Accepted values include html, markdown, docx,
        xlsx, pdf, svg, and drawio. Defaults to html, markdown, svg.

    .OUTPUTS
        None. Reports are written to the output directory.

    .EXAMPLE
        Export-AzureLocalRangerReport -ManifestPath 'C:\AzureLocalRanger\2025-01-15\ranger-manifest.json'

    .EXAMPLE
        Export-AzureLocalRangerReport `
            -ManifestPath .\ranger-manifest.json `
            -Formats html,markdown `
            -OutputPath C:\Reports
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ManifestPath,

        [string]$OutputPath,
        [string[]]$Formats = @('html', 'markdown', 'svg')
    )

    $resolvedManifestPath = Resolve-RangerPath -Path $ManifestPath
    if (-not (Test-Path -Path $resolvedManifestPath)) {
        throw "Manifest file not found: $resolvedManifestPath"
    }

    $manifest = Get-Content -Path $resolvedManifestPath -Raw | ConvertFrom-Json -AsHashtable -Depth 100
    $packageRoot = if ($OutputPath) { Resolve-RangerPath -Path $OutputPath } else { Split-Path -Parent $resolvedManifestPath }
    Invoke-RangerOutputGeneration -Manifest (ConvertTo-RangerHashtable -InputObject $manifest) -PackageRoot $packageRoot -Formats $Formats -Mode $manifest['run']['mode']
}

function Test-AzureLocalRangerPrerequisites {
    <#
    .SYNOPSIS
        Validates that all prerequisites for running AzureLocalRanger are satisfied.

    .DESCRIPTION
        Test-AzureLocalRangerPrerequisites checks for the required PowerShell version,
        WinRM cmdlets, RSAT Active Directory module, clustering cmdlets, Hyper-V cmdlets,
        the Az PowerShell modules, and Azure CLI. It also validates the provided
        configuration file.

        When -InstallPrerequisites is specified, the command automatically installs any
        missing components. RSAT-AD-PowerShell is installed via Install-WindowsFeature on
        Windows Server or Add-WindowsCapability on Windows Client / AVD multi-session.
        Az modules are installed from PSGallery with -Scope CurrentUser. An elevated
        (Administrator) session is required when -InstallPrerequisites is used.

    .PARAMETER ConfigPath
        Optional path to a Ranger configuration file to include in the validation pass.

    .PARAMETER ConfigObject
        Optional in-memory configuration hashtable to include in the validation pass.

    .PARAMETER InstallPrerequisites
        Automatically install missing RSAT AD and Az PowerShell modules. Requires
        an elevated (Administrator) session.

    .OUTPUTS
        System.Collections.Hashtable — contains Validation, SelectedCollectors, and Checks keys.

    .EXAMPLE
        # Check prerequisites without a config
        Test-AzureLocalRangerPrerequisites

    .EXAMPLE
        # Validate against a config file
        Test-AzureLocalRangerPrerequisites -ConfigPath .\ranger.yml

    .EXAMPLE
        # Auto-install missing components (requires elevated session)
        Test-AzureLocalRangerPrerequisites -ConfigPath .\ranger.yml -InstallPrerequisites
    #>

    # Issue #78: -InstallPrerequisites auto-installs RSAT AD and Az modules when missing.
    # Requires an elevated (Administrator) session. Detects Server vs Client OS and uses
    # Install-WindowsFeature (Server) or Add-WindowsCapability (Client) for RSAT AD.
    [CmdletBinding()]
    param(
        [string]$ConfigPath,
        $ConfigObject,
        [switch]$InstallPrerequisites
        ,
        [string]$ClusterFqdn,
        [string[]]$ClusterNodes,
        [string]$EnvironmentName,
        [string]$SubscriptionId,
        [string]$TenantId,
        [string]$ResourceGroup
    )

    if ($InstallPrerequisites) {
        $isElevated = ([Security.Principal.WindowsPrincipal]::new(
            [Security.Principal.WindowsIdentity]::GetCurrent()
        )).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)

        if (-not $isElevated) {
            throw '-InstallPrerequisites requires an elevated (Administrator) PowerShell session.'
        }

        # RSAT AD (ActiveDirectory PS module)
        if (-not (Test-RangerCommandAvailable -Name 'Get-ADUser')) {
            if (Get-Command Install-WindowsFeature -ErrorAction SilentlyContinue) {
                # Windows Server — ServerManager cmdlet available
                Write-Verbose 'Installing RSAT-AD-PowerShell via Install-WindowsFeature (Server OS)...'
                Install-WindowsFeature -Name RSAT-AD-PowerShell -ErrorAction Stop | Out-Null
            } else {
                # Windows client or multi-session (Win10/11/AVD) — use DISM capability
                Write-Verbose 'Installing RSAT ActiveDirectory via Add-WindowsCapability (Client/multi-session OS)...'
                Add-WindowsCapability -Online -Name 'Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0' -ErrorAction Stop | Out-Null
            }
        }

        # Az modules required by Ranger collectors
        $azModulesNeeded = @('Az.Accounts', 'Az.Resources', 'Az.DesktopVirtualization', 'Az.Aks', 'Az.KeyVault')
        foreach ($mod in $azModulesNeeded) {
            if (-not (Get-Module -ListAvailable -Name $mod)) {
                Write-Verbose "Installing $mod from PSGallery..."
                Install-Module -Name $mod -Repository PSGallery -Force -AllowClobber -Scope CurrentUser -ErrorAction Stop
            }
        }
    }

    $config = Import-RangerConfiguration -ConfigPath $ConfigPath -ConfigObject $ConfigObject
    $structuralOverrides = @{}
    if ($PSBoundParameters.ContainsKey('ClusterFqdn'))     { $structuralOverrides['ClusterFqdn']     = $ClusterFqdn }
    if ($PSBoundParameters.ContainsKey('ClusterNodes'))    { $structuralOverrides['ClusterNodes']    = $ClusterNodes }
    if ($PSBoundParameters.ContainsKey('EnvironmentName')) { $structuralOverrides['EnvironmentName'] = $EnvironmentName }
    if ($PSBoundParameters.ContainsKey('SubscriptionId'))  { $structuralOverrides['SubscriptionId']  = $SubscriptionId }
    if ($PSBoundParameters.ContainsKey('TenantId'))        { $structuralOverrides['TenantId']        = $TenantId }
    if ($PSBoundParameters.ContainsKey('ResourceGroup'))   { $structuralOverrides['ResourceGroup']   = $ResourceGroup }
    $config = Set-RangerStructuralOverrides -Config $config -StructuralOverrides $structuralOverrides
    $validation = Test-RangerConfiguration -Config $config -PassThru
    $selectedCollectors = Resolve-RangerSelectedCollectors -Config $config
    $probeConfig = ConvertTo-RangerHashtable -InputObject $config
    $probeConfig.behavior.promptForMissingCredentials = $false
    $clusterConnectivityPassed = $false
    $clusterConnectivityDetail = 'Cluster WinRM connectivity not tested.'

    try {
        $probeCredentialMap = Resolve-RangerCredentialMap -Config $probeConfig -Overrides @{}
        $probeTargets = [System.Collections.Generic.List[string]]::new()
        if (-not [string]::IsNullOrWhiteSpace($probeConfig.targets.cluster.fqdn)) {
            $probeTargets.Add([string]$probeConfig.targets.cluster.fqdn)
        }
        foreach ($node in @($probeConfig.targets.cluster.nodes)) {
            if (-not [string]::IsNullOrWhiteSpace([string]$node) -and $node -notin $probeTargets) {
                $probeTargets.Add([string]$node)
            }
        }

        if ($probeTargets.Count -eq 0) {
            $clusterConnectivityDetail = 'No cluster WinRM targets are configured.'
        }
        elseif (-not $probeCredentialMap.cluster) {
            $clusterConnectivityDetail = 'Cluster credential could not be resolved; connectivity was not tested.'
        }
        else {
            $probeResults = @($probeTargets | ForEach-Object {
                [ordered]@{ target = $_; result = Test-RangerWinRmTarget -ComputerName $_ -Credential $probeCredentialMap.cluster }
            })
            $clusterConnectivityPassed = @($probeResults | Where-Object { $_.result.Reachable }).Count -gt 0
            $clusterConnectivityDetail = @($probeResults | ForEach-Object {
                $status = if ($_.result.Reachable) {
                    "reachable over $($_.result.Transport.ToUpperInvariant())/$($_.result.Port)"
                }
                else {
                    $_.result.Message
                }
                "$($_.target): $status"
            }) -join '; '
        }
    }
    catch {
        $clusterConnectivityDetail = "Cluster WinRM connectivity probe failed: $($_.Exception.Message)"
    }

    $checks = @(
        [ordered]@{ Name = 'PowerShell 7+'; Passed = $PSVersionTable.PSVersion.Major -ge 7; Detail = $PSVersionTable.PSVersion.ToString() },
        [ordered]@{ Name = 'WinRM cmdlets'; Passed = (Test-RangerCommandAvailable -Name 'Invoke-Command'); Detail = 'Invoke-Command' },
        [ordered]@{ Name = 'Cluster WinRM connectivity'; Passed = $clusterConnectivityPassed; Detail = $clusterConnectivityDetail },
        [ordered]@{ Name = 'RSAT AD'; Passed = (Test-RangerCommandAvailable -Name 'Get-ADUser'); Detail = 'Get-ADUser (required for identity domain collection)' },
        [ordered]@{ Name = 'Cluster cmdlets'; Passed = (Test-RangerCommandAvailable -Name 'Get-Cluster'); Detail = 'Get-Cluster (optional on the runner, required on cluster nodes)' },
        [ordered]@{ Name = 'Hyper-V cmdlets'; Passed = (Test-RangerCommandAvailable -Name 'Get-VM'); Detail = 'Get-VM (optional on the runner, required on cluster nodes)' },
        [ordered]@{ Name = 'Az modules'; Passed = (Test-RangerCommandAvailable -Name 'Get-AzContext'); Detail = 'Get-AzContext' },
        [ordered]@{ Name = 'Azure CLI'; Passed = (Test-RangerCommandAvailable -Name 'az'); Detail = 'az (optional fallback)' },
        [ordered]@{ Name = 'Pester'; Passed = (Test-RangerCommandAvailable -Name 'Invoke-Pester'); Detail = 'Invoke-Pester' }
    )

    [ordered]@{
        Validation         = $validation
        SelectedCollectors = @($selectedCollectors | ForEach-Object { $_.Id })
        Checks             = $checks
    }
}