# ============================================================ # Write-MigrationReport.ps1 # ============================================================ # Writes a unified CSV report for all processed servers # (Windows + Linux) and optionally a rollback JSON file. # Also contains Write-RollbackFile (Task 10). # ============================================================ function Write-MigrationReport { <# .SYNOPSIS Writes a unified CSV migration report for all processed servers. .DESCRIPTION Accepts an array of result objects from Set-WindowsDns and Invoke-LinuxDns. Writes to CSV with -Append so re-runs don't overwrite prior results. Prints a summary table to the console. .PARAMETER Results Array of PSCustomObjects returned by the DNS change functions. .PARAMETER OutputPath Full path to the CSV file. Created if it does not exist. .PARAMETER Append If set, appends to an existing CSV. Default: $true #> [CmdletBinding()] param( [Parameter(Mandatory)] [PSCustomObject[]]$Results, [Parameter(Mandatory)] [string]$OutputPath, [switch]$NoAppend ) # Ensure output directory exists $outputDir = Split-Path $OutputPath -Parent if (-not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir -Force | Out-Null } # Normalise results to CSV-safe objects (strip internal _RollbackData field) $csvRows = foreach ($r in $Results) { [PSCustomObject]@{ Hostname = $r.Hostname ResolvedIP = $r.ResolvedIP OS = $r.OS Distro = $r.Distro OldDNS = $r.OldDNS NewDNS = $r.NewDNS ConnectionMethod = $r.ConnectionMethod ChangeStatus = $r.ChangeStatus ValidationStatus = $r.ValidationStatus ModifiedFiles = $r.ModifiedFiles ServiceRestarted = $r.ServiceRestarted ErrorMessage = $r.ErrorMessage DryRun = $r.DryRun Timestamp = $r.Timestamp } } # Write CSV if ($NoAppend -or -not (Test-Path $OutputPath)) { $csvRows | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 } else { $csvRows | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 -Append } # ── Console summary table ───────────────────────────────────────────────── $total = $Results.Count $success = @($Results | Where-Object { $_.ChangeStatus -eq "Success" }).Count $dryRun = @($Results | Where-Object { $_.ChangeStatus -eq "DryRun" }).Count $failed = @($Results | Where-Object { $_.ChangeStatus -eq "Failed" }).Count $connFailed = @($Results | Where-Object { $_.ChangeStatus -eq "ConnectionFailed" }).Count $skipped = @($Results | Where-Object { $_.ChangeStatus -eq "Skipped" }).Count $valSuccess = @($Results | Where-Object { $_.ValidationStatus -eq "Success" }).Count $valFailed = @($Results | Where-Object { $_.ValidationStatus -eq "ValidationFailed" }).Count Write-Host "" Write-Host "═══════════════════════════════════════════════════════════════" -ForegroundColor Cyan Write-Host " DNS MIGRATION REPORT SUMMARY" -ForegroundColor Cyan Write-Host "═══════════════════════════════════════════════════════════════" -ForegroundColor Cyan Write-Host (" Total servers processed : {0}" -f $total) Write-Host (" DNS change success : {0}" -f $success) -ForegroundColor Green Write-Host (" DNS change failed : {0}" -f $failed) -ForegroundColor $(if ($failed -gt 0) { "Red" } else { "White" }) Write-Host (" Connection failed : {0}" -f $connFailed) -ForegroundColor $(if ($connFailed -gt 0) { "Red" } else { "White" }) Write-Host (" Skipped (unresolvable) : {0}" -f $skipped) -ForegroundColor $(if ($skipped -gt 0) { "Yellow" } else { "White" }) Write-Host (" Dry run (no changes) : {0}" -f $dryRun) -ForegroundColor $(if ($dryRun -gt 0) { "Cyan" } else { "White" }) Write-Host "───────────────────────────────────────────────────────────────" -ForegroundColor Cyan Write-Host (" Validation success : {0}" -f $valSuccess) -ForegroundColor Green Write-Host (" Validation failed : {0}" -f $valFailed) -ForegroundColor $(if ($valFailed -gt 0) { "Yellow" } else { "White" }) Write-Host "═══════════════════════════════════════════════════════════════" -ForegroundColor Cyan Write-Host (" Report written to: {0}" -f $OutputPath) -ForegroundColor Gray Write-Host "" # ── Per-server table (truncated to 80 chars per row for readability) ─────── $colWidth = @{ H=32; S=18; V=18; E=30 } $header = "{0,-$($colWidth.H)} {1,-$($colWidth.S)} {2,-$($colWidth.V)} {3}" ` -f "Hostname", "ChangeStatus", "ValidationStatus", "Error/Note" $divider = "-" * ($colWidth.H + $colWidth.S + $colWidth.V + $colWidth.E + 3) Write-Host $divider Write-Host $header Write-Host $divider foreach ($r in $Results | Sort-Object OS, Hostname) { $changeColor = switch ($r.ChangeStatus) { "Success" { "Green" } "DryRun" { "Cyan" } "Failed" { "Red" } "ConnectionFailed"{ "Red" } "Skipped" { "Yellow" } default { "White" } } $valColor = switch ($r.ValidationStatus) { "Success" { "Green" } "ValidationFailed"{ "Yellow" } "DryRun_OK" { "Cyan" } "DryRun_NoResolve"{ "Yellow" } default { "Gray" } } $hn = $r.Hostname.PadRight($colWidth.H).Substring(0, [Math]::Min($r.Hostname.Length, $colWidth.H)).PadRight($colWidth.H) $st = $r.ChangeStatus.PadRight($colWidth.S) $vl = $r.ValidationStatus.PadRight($colWidth.V) $err = if ($r.ErrorMessage) { $r.ErrorMessage.Substring(0, [Math]::Min($r.ErrorMessage.Length, $colWidth.E)) } else { "" } Write-Host ("{0} " -f $hn) -NoNewline Write-Host ("{0} " -f $st) -NoNewline -ForegroundColor $changeColor Write-Host ("{0} " -f $vl) -NoNewline -ForegroundColor $valColor Write-Host $err } Write-Host $divider Write-Host "" } function Write-RollbackFile { <# .SYNOPSIS Serializes pre-change DNS state to a timestamped JSON rollback file. .DESCRIPTION Called BEFORE any DNS changes are made. Captures: - Windows: NIC name + old DNS IP list - Linux: modified file paths + base64 ContentBefore + service name The rollback file is written atomically covering all discovered servers, even if the migration script is interrupted mid-run. .PARAMETER Results Array of result objects. For pre-change capture, these should have OldDNS populated (from read-only probe) and _RollbackData for Linux. .PARAMETER Results Array of result objects from Set-WindowsDns / Invoke-LinuxDns. Each entry's _RollbackData property carries the Linux file content. For Windows entries, the NICName is extracted from the ModifiedFiles field. .PARAMETER OutputDir Directory to write the rollback JSON file. .OUTPUTS Full path to the written rollback JSON file. #> [CmdletBinding()] param( [PSCustomObject[]]$Results, [string]$OutputDir = ".\logs" ) if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null } $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $filePath = Join-Path $OutputDir "dns-rollback-$timestamp.json" $entries = [System.Collections.Generic.List[object]]::new() foreach ($r in $Results) { if ($r.OS -eq "Windows") { # Windows rollback: NIC name + old DNS IPs $nicName = if ($r.ModifiedFiles -match "NIC:(.+)") { $Matches[1] } else { "" } $entries.Add([ordered]@{ Hostname = $r.Hostname OS = "Windows" Distro = $r.Distro ConnectionMethod = $r.ConnectionMethod OldDNS = @($r.OldDNS -split ";" | Where-Object { $_ -ne "" }) NewDNS = @($r.NewDNS -split ";" | Where-Object { $_ -ne "" }) NICName = $nicName ModifiedFiles = @() ServiceRestarted = "" ResolvConfBefore = "" ResolvConfAfter = "" Timestamp = $r.Timestamp }) } elseif ($r.OS -eq "Linux") { # Linux rollback: full file contents from _RollbackData $rd = $r._RollbackData if ($null -ne $rd) { # Normalise ModifiedFiles to serialisable format $mf = @($rd.ModifiedFiles | ForEach-Object { [ordered]@{ Path = $_.Path ContentBefore = $_.ContentBefore ContentAfter = $_.ContentAfter } }) $entries.Add([ordered]@{ Hostname = $rd.Hostname OS = "Linux" Distro = $rd.Distro ConnectionMethod = $rd.ConnectionMethod OldDNS = @($rd.OldDNS | Where-Object { $_ -ne "" }) NewDNS = @($rd.NewDNS | Where-Object { $_ -ne "" }) NICName = $rd.NICName ModifiedFiles = $mf ServiceRestarted = $rd.ServiceRestarted ResolvConfBefore = $rd.ResolvConfBefore ResolvConfAfter = $rd.ResolvConfAfter Timestamp = $rd.Timestamp }) } else { # No rollback data — record minimal entry so it appears in rollback file $entries.Add([ordered]@{ Hostname = $r.Hostname OS = "Linux" Distro = $r.Distro ConnectionMethod = "SSH" OldDNS = @($r.OldDNS -split ";" | Where-Object { $_ -ne "" }) NewDNS = @($r.NewDNS -split ";" | Where-Object { $_ -ne "" }) NICName = "" ModifiedFiles = @() ServiceRestarted = $r.ServiceRestarted ResolvConfBefore = "" ResolvConfAfter = "" Timestamp = $r.Timestamp }) } } } $rollbackData = [ordered]@{ SchemaVersion = "1.0" CreatedAt = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ") TotalServers = $entries.Count Servers = $entries.ToArray() } $rollbackData | ConvertTo-Json -Depth 10 | Set-Content -Path $filePath -Encoding UTF8 Write-Host " Rollback file written: $filePath" -ForegroundColor Green return $filePath } function Read-RollbackFile { <# .SYNOPSIS Reads and validates a rollback JSON file. Returns the parsed object. .PARAMETER Path Path to the dns-rollback-.json file. #> param( [Parameter(Mandatory)] [string]$Path ) if (-not (Test-Path $Path)) { throw "Rollback file not found: $Path" } try { $data = Get-Content $Path -Raw -Encoding UTF8 | ConvertFrom-Json } catch { throw "Failed to parse rollback file '$Path': $($_.Exception.Message)" } if ($null -eq $data.Servers) { throw "Rollback file has no 'Servers' array: $Path" } Write-Host " Rollback file loaded: $Path" -ForegroundColor Cyan Write-Host (" Contains {0} server entries (created {1})" -f $data.TotalServers, $data.CreatedAt) -ForegroundColor Gray return $data }