Start-AzureLandingZoneServer.ps1

#!/usr/bin/env pwsh
#Requires -Version 7.0
<#
.SYNOPSIS
    Azure Landing Zone Inventory Web Server
.DESCRIPTION
    Starts a web server that provides a live view of Azure Landing Zone inventory
    with authentication, navigation, and PDF export capabilities.
.PARAMETER Port
    Port number for the web server (default: 8080)
.NOTES
    Requires PowerShell 7.0 or higher
#>


param(
    [int]$Port = 8080
)

# Import required modules
$ErrorActionPreference = "Stop"

Write-Host "🚀 Starting Azure Landing Zone Inventory Server..." -ForegroundColor Cyan

# Check PowerShell version
if ($PSVersionTable.PSVersion.Major -lt 7) {
    Write-Host "❌ ERROR: PowerShell 7 or higher is required." -ForegroundColor Red
    Write-Host " Current version: $($PSVersionTable.PSVersion)" -ForegroundColor Yellow
    Write-Host " Download PowerShell 7+: https://aka.ms/powershell" -ForegroundColor Cyan
    exit 1
}

Write-Host "✓ PowerShell version: $($PSVersionTable.PSVersion)" -ForegroundColor Green

# Check and import Azure modules
Write-Host "📦 Checking required PowerShell modules..." -ForegroundColor Cyan

$requiredModules = @(
    'Az.Accounts', 
    'Az.Resources', 
    'Az.Network', 
    'Az.PolicyInsights'
)

function Update-RequiredModule {
    param(
        [Parameter(Mandatory=$true)]
        [string]$ModuleName
    )
    
    try {
        $installedModule = Get-Module -ListAvailable -Name $ModuleName | Sort-Object Version -Descending | Select-Object -First 1
        
        if (-not $installedModule) {
            Write-Host " ⬇️ Installing $ModuleName..." -ForegroundColor Yellow
            Install-Module -Name $ModuleName -Force -Scope CurrentUser -AllowClobber -ErrorAction Stop
            $installedModule = Get-Module -ListAvailable -Name $ModuleName | Sort-Object Version -Descending | Select-Object -First 1
            Write-Host " ✓ Installed $ModuleName v$($installedModule.Version)" -ForegroundColor Green
        } else {
            # Check for updates
            try {
                $onlineModule = Find-Module -Name $ModuleName -ErrorAction Stop
                
                if ($onlineModule.Version -gt $installedModule.Version) {
                    Write-Host " ⬆️ Updating $ModuleName from v$($installedModule.Version) to v$($onlineModule.Version)..." -ForegroundColor Yellow
                    Update-Module -Name $ModuleName -Force -ErrorAction Stop
                    Write-Host " ✓ Updated $ModuleName to v$($onlineModule.Version)" -ForegroundColor Green
                } else {
                    Write-Host " ✓ $ModuleName v$($installedModule.Version) (latest)" -ForegroundColor Green
                }
            } catch {
                # If online check fails (e.g., no internet), just use installed version
                Write-Host " ✓ $ModuleName v$($installedModule.Version) (online check skipped)" -ForegroundColor Gray
            }
        }
        
        # Import the module
        Import-Module $ModuleName -ErrorAction Stop -Force
        return $true
    } catch {
        Write-Host " ✗ Error with ${ModuleName}: $($_.Exception.Message)" -ForegroundColor Red
        return $false
    }
}

$allModulesOk = $true
foreach ($module in $requiredModules) {
    if (-not (Update-RequiredModule -ModuleName $module)) {
        $allModulesOk = $false
    }
}

if (-not $allModulesOk) {
    Write-Host "❌ Not all required modules are available. Please resolve module issues and try again." -ForegroundColor Red
    exit 1
}

# Import inventory collection module
$inventoryModulePath = Join-Path $PSScriptRoot "Get-AzureLandingZoneInventory.ps1"
. $inventoryModulePath

# Check Azure connection
function Test-AzureConnection {
    try {
        $context = Get-AzContext
        if ($null -eq $context) {
            return $false
        }
        return $true
    }
    catch {
        return $false
    }
}

# Global state
$script:IsAuthenticated = Test-AzureConnection
$script:InventoryData = @{}
$script:LastUpdate = $null
$script:CollectionRunspace = $null
$script:CollectionPipeline = $null
$script:CollectionInProgress = $false
$script:ProgressFilePath = Join-Path ([System.IO.Path]::GetTempPath()) "azlz-inventory-progress.json"

Write-Host "🔐 Azure Authentication Status: $(if ($script:IsAuthenticated) { 'Connected ✓' } else { 'Not Connected ✗' })" -ForegroundColor $(if ($script:IsAuthenticated) { 'Green' } else { 'Yellow' })

# Test Management Group access if authenticated
if ($script:IsAuthenticated) {
    try {
        $ctx = Get-AzContext
        Write-Host " 📋 Context: $($ctx.Account.Id) @ Tenant: $($ctx.Tenant.Id)" -ForegroundColor Gray
        
        $testMg = @(Get-AzManagementGroup -ErrorAction Stop)
        Write-Host " ✓ Management Groups accessible ($($testMg.Count) found)" -ForegroundColor Green
    } catch {
        Write-Host " ⚠️ Management Groups NOT accessible: $($_.Exception.Message)" -ForegroundColor Yellow
        Write-Host " ℹ️ You may need 'Management Group Reader' role at tenant root" -ForegroundColor Cyan
    }
}

# HTTP Listener
$listener = New-Object System.Net.HttpListener
$listener.Prefixes.Add("http://localhost:$Port/")
$listener.Start()

Write-Host "🌐 Server started at http://localhost:$Port" -ForegroundColor Green
Write-Host "📊 Access the Azure Landing Zone Inventory Dashboard in your browser" -ForegroundColor Cyan
Write-Host "Press Ctrl+C to stop the server" -ForegroundColor Gray
Write-Host ""

try {
    while ($listener.IsListening) {
        $context = $listener.GetContext()
        $request = $context.Request
        $response = $context.Response
        
        $path = $request.Url.AbsolutePath
        $method = $request.HttpMethod
        
        Write-Host "$(Get-Date -Format 'HH:mm:ss') $method $path" -ForegroundColor Gray
        
        # Content type and response
        $content = ""
        $contentType = "text/html; charset=utf-8"
        
        # Route handling
        switch -Regex ($path) {
            '^/$' {
                # Serve main page
                $indexPath = Join-Path $PSScriptRoot "index.html"
                if (Test-Path $indexPath) {
                    $content = Get-Content $indexPath -Raw
                } else {
                    $content = "<html><body><h1>Azure Landing Zone Inventory Server</h1><p>index.html not found</p></body></html>"
                }
            }
            
            '^/styles\.css$' {
                $cssPath = Join-Path $PSScriptRoot "styles.css"
                if (Test-Path $cssPath) {
                    $content = Get-Content $cssPath -Raw
                    $contentType = "text/css"
                }
            }
            
            '^/app\.js$' {
                $jsPath = Join-Path $PSScriptRoot "app.js"
                if (Test-Path $jsPath) {
                    $content = Get-Content $jsPath -Raw
                    $contentType = "application/javascript"
                }
            }
            
            '^/scoring-config\.json$' {
                $configPath = Join-Path $PSScriptRoot "scoring-config.json"
                if (Test-Path $configPath) {
                    $content = Get-Content $configPath -Raw
                    $contentType = "application/json"
                } else {
                    $content = @{
                        error = "Scoring configuration file not found"
                        message = "scoring-config.json is missing from the application directory"
                    } | ConvertTo-Json
                    $contentType = "application/json"
                }
            }
            
            '^/waf-config\.json$' {
                $configPath = Join-Path $PSScriptRoot "waf-config.json"
                if (Test-Path $configPath) {
                    $content = Get-Content $configPath -Raw
                    $contentType = "application/json"
                } else {
                    $content = @{
                        error = "WAF configuration file not found"
                        message = "waf-config.json is missing from the application directory"
                    } | ConvertTo-Json
                    $contentType = "application/json"
                }
            }
            
            '^/api/auth/status$' {
                $script:IsAuthenticated = Test-AzureConnection
                $authStatus = @{
                    authenticated = $script:IsAuthenticated
                    context = if ($script:IsAuthenticated) {
                        $ctx = Get-AzContext
                        @{
                            account = $ctx.Account.Id
                            subscription = $ctx.Subscription.Name
                            tenant = $ctx.Tenant.Id
                        }
                    } else { $null }
                }
                $content = $authStatus | ConvertTo-Json
                $contentType = "application/json"
            }
            
            '^/api/auth/login$' {
                try {
                    Connect-AzAccount -UseDeviceAuthentication | Out-Null
                    $script:IsAuthenticated = $true
                    
                    # Test management group access
                    $mgAccessible = $false
                    $mgCount = 0
                    try {
                        $testMg = @(Get-AzManagementGroup -ErrorAction Stop)
                        $mgAccessible = $true
                        $mgCount = $testMg.Count
                    } catch {
                        Write-Host " ⚠️ Management Groups not accessible: $($_.Exception.Message)" -ForegroundColor Yellow
                    }
                    
                    $content = @{ 
                        success = $true
                        message = "Authentication successful"
                        managementGroupsAccessible = $mgAccessible
                        managementGroupCount = $mgCount
                    } | ConvertTo-Json
                } catch {
                    $content = @{ success = $false; message = $_.Exception.Message } | ConvertTo-Json
                }
                $contentType = "application/json"
            }
            
            '^/api/inventory/data$' {
                if ($script:IsAuthenticated) {
                    if ($script:CollectionInProgress) {
                        # Collection is running in background — tell client to poll /api/progress
                        $content = @{ collecting = $true; message = "Collection in progress" } | ConvertTo-Json
                    } elseif ($script:InventoryData -and $script:InventoryData.Count -gt 0 -and $script:InventoryData.ContainsKey('version')) {
                        # Return cached data
                        $content = $script:InventoryData | ConvertTo-Json -Depth 20
                    } else {
                        # Start collection in background runspace
                        try {
                            Write-Host " 📊 Starting Azure Landing Zone inventory collection..." -ForegroundColor Cyan
                            
                            # Clear previous progress file
                            if (Test-Path $script:ProgressFilePath) { Remove-Item $script:ProgressFilePath -Force -ErrorAction SilentlyContinue }
                            
                            # Save Azure context so the background runspace can authenticate
                            $azContextPath = Join-Path ([System.IO.Path]::GetTempPath()) "azlz-az-context.json"
                            Save-AzContext -Path $azContextPath -Force | Out-Null
                            
                            $script:CollectionInProgress = $true
                            $script:CollectionRunspace = [runspacefactory]::CreateRunspace()
                            $script:CollectionRunspace.Open()
                            
                            $script:CollectionPipeline = [powershell]::Create()
                            $script:CollectionPipeline.Runspace = $script:CollectionRunspace
                            
                            $inventoryScript = $inventoryModulePath
                            $script:CollectionPipeline.AddScript({
                                param($scriptPath, $ctxPath)
                                Import-Module Az.Accounts, Az.Resources, Az.Network, Az.PolicyInsights -ErrorAction Stop
                                Import-AzContext -Path $ctxPath -ErrorAction Stop | Out-Null
                                . $scriptPath
                                $result = Get-AzureLandingZoneInventory
                                return $result
                            }).AddArgument($inventoryScript).AddArgument($azContextPath) | Out-Null
                            
                            $script:CollectionHandle = $script:CollectionPipeline.BeginInvoke()
                            
                            $content = @{ collecting = $true; message = "Collection started" } | ConvertTo-Json
                        } catch {
                            $script:CollectionInProgress = $false
                            $content = @{ error = $_.Exception.Message } | ConvertTo-Json
                        }
                    }
                } else {
                    $response.StatusCode = 401
                    $content = @{ error = "Not authenticated" } | ConvertTo-Json
                }
                $contentType = "application/json"
            }
            
            '^/api/progress$' {
                if ($script:CollectionInProgress -and $script:CollectionHandle) {
                    # Check if collection has completed
                    if ($script:CollectionHandle.IsCompleted) {
                        $completedStatus = 'Complete!'
                        try {
                            # Check for pipeline errors first
                            if ($script:CollectionPipeline.HadErrors) {
                                $errMsgs = $script:CollectionPipeline.Streams.Error | ForEach-Object { $_.ToString() }
                                $errText = ($errMsgs | Select-Object -First 3) -join '; '
                                Write-Host " ⚠ Collection completed with errors: $errText" -ForegroundColor Yellow
                            }
                            $script:InventoryData = $script:CollectionPipeline.EndInvoke($script:CollectionHandle)
                            # EndInvoke returns a PSDataCollection, extract the hashtable
                            if ($script:InventoryData -is [System.Management.Automation.PSDataCollection[PSObject]]) {
                                $script:InventoryData = $script:InventoryData[0]
                            }
                            if (-not $script:InventoryData -or ($script:InventoryData -is [hashtable] -and $script:InventoryData.Count -eq 0)) {
                                throw "Collection returned empty data — Azure context may not have transferred to background process."
                            }
                            $script:LastUpdate = Get-Date
                            Write-Host " ✓ Inventory collection complete" -ForegroundColor Green
                        } catch {
                            Write-Host " ✗ Inventory collection error: $($_.Exception.Message)" -ForegroundColor Red
                            $script:InventoryData = @{ error = $_.Exception.Message }
                            $completedStatus = "Error: $($_.Exception.Message)"
                        } finally {
                            $script:CollectionPipeline.Dispose()
                            $script:CollectionRunspace.Close()
                            $script:CollectionRunspace.Dispose()
                            $script:CollectionInProgress = $false
                            $script:CollectionPipeline = $null
                            $script:CollectionRunspace = $null
                            $script:CollectionHandle = $null
                        }
                        $content = @{ 
                            completed = $true 
                            percentage = 100 
                            status = $completedStatus
                        } | ConvertTo-Json
                    } else {
                        # Read progress from temp file
                        $progressInfo = @{ completed = $false; percentage = 0; status = 'Starting...' }
                        if (Test-Path $script:ProgressFilePath) {
                            try {
                                $rawProgress = [System.IO.File]::ReadAllText($script:ProgressFilePath)
                                $fileProgress = $rawProgress | ConvertFrom-Json -ErrorAction SilentlyContinue
                                if ($fileProgress) {
                                    $progressInfo.percentage = $fileProgress.percentage
                                    $progressInfo.status = $fileProgress.status
                                    $progressInfo.step = $fileProgress.step
                                    $progressInfo.totalSteps = $fileProgress.totalSteps
                                }
                            } catch {
                                # File might be mid-write, use defaults
                            }
                        }
                        $content = $progressInfo | ConvertTo-Json
                    }
                } else {
                    $content = @{ 
                        completed = -not $script:CollectionInProgress
                        percentage = if ($script:InventoryData.Count -gt 0) { 100 } else { 0 }
                        status = if ($script:InventoryData.Count -gt 0) { 'Complete!' } else { 'Idle' }
                    } | ConvertTo-Json
                }
                $contentType = "application/json"
            }
            
            '^/api/inventory/refresh$' {
                if ($script:IsAuthenticated) {
                    if ($script:CollectionInProgress) {
                        $content = @{ success = $false; message = "Collection already in progress" } | ConvertTo-Json
                    } else {
                        try {
                            Write-Host " 🔄 Starting inventory refresh..." -ForegroundColor Cyan
                            
                            # Clear previous progress file
                            if (Test-Path $script:ProgressFilePath) { Remove-Item $script:ProgressFilePath -Force -ErrorAction SilentlyContinue }
                            
                            # Reset cached data so /api/inventory/data triggers new collection
                            $script:InventoryData = @{}
                            
                            # Save Azure context so the background runspace can authenticate
                            $azContextPath = Join-Path ([System.IO.Path]::GetTempPath()) "azlz-az-context.json"
                            Save-AzContext -Path $azContextPath -Force | Out-Null
                            
                            $script:CollectionInProgress = $true
                            $script:CollectionRunspace = [runspacefactory]::CreateRunspace()
                            $script:CollectionRunspace.Open()
                            
                            $script:CollectionPipeline = [powershell]::Create()
                            $script:CollectionPipeline.Runspace = $script:CollectionRunspace
                            
                            $inventoryScript = $inventoryModulePath
                            $script:CollectionPipeline.AddScript({
                                param($scriptPath, $ctxPath)
                                Import-Module Az.Accounts, Az.Resources, Az.Network, Az.PolicyInsights -ErrorAction Stop
                                Import-AzContext -Path $ctxPath -ErrorAction Stop | Out-Null
                                . $scriptPath
                                $result = Get-AzureLandingZoneInventory
                                return $result
                            }).AddArgument($inventoryScript).AddArgument($azContextPath) | Out-Null
                            
                            $script:CollectionHandle = $script:CollectionPipeline.BeginInvoke()
                            
                            $content = @{ 
                                success = $true
                                message = "Refresh started"
                                collecting = $true
                            } | ConvertTo-Json
                        } catch {
                            $script:CollectionInProgress = $false
                            $content = @{ success = $false; error = $_.Exception.Message } | ConvertTo-Json
                        }
                    }
                } else {
                    $response.StatusCode = 401
                    $content = @{ success = $false; error = "Not authenticated" } | ConvertTo-Json
                }
                $contentType = "application/json"
            }
            
            default {
                $response.StatusCode = 404
                $content = "<html><body><h1>404 - Not Found</h1></body></html>"
            }
        }
        
        # Send response
        $buffer = [System.Text.Encoding]::UTF8.GetBytes($content)
        $response.ContentLength64 = $buffer.Length
        $response.ContentType = $contentType
        $response.Headers.Add("Access-Control-Allow-Origin", "*")
        $response.OutputStream.Write($buffer, 0, $buffer.Length)
        $response.Close()
    }
}
finally {
    Write-Host "`n🛑 Stopping server..." -ForegroundColor Yellow
    $listener.Stop()
    $listener.Close()
    Write-Host "✓ Server stopped" -ForegroundColor Green
}