diff --git a/.gitignore b/.gitignore index e43b0f9..cb50e10 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .DS_Store +AzRetirementMonitor-History.json diff --git a/AzRetirementMonitor.psd1 b/AzRetirementMonitor.psd1 index 5b40e5e..c8f39b8 100644 --- a/AzRetirementMonitor.psd1 +++ b/AzRetirementMonitor.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'AzRetirementMonitor.psm1' - ModuleVersion = '2.0.0' + ModuleVersion = '3.0.0' GUID = '6775bae9-a3ec-43de-abd9-14308dd345c4' Author = 'Corey Callaway' CompanyName = 'Independent' @@ -22,14 +22,17 @@ LicenseUri = 'https://github.com/cocallaw/AzRetirementMonitor/blob/main/LICENSE' ProjectUri = 'https://github.com/cocallaw/AzRetirementMonitor' ReleaseNotes = @' +## Version 3.0.0 - Change Tracking +- **New**: `-EnableChangeTracking` parameter on `Get-AzRetirementRecommendation` to monitor progress over time +- **New**: `-ChangeTrackingPath` parameter to specify a custom history file location (defaults to `AzRetirementMonitor-History.json` in the current directory) +- Snapshots of each run are stored in a JSON history file +- Console output shows comparison with the previous run: total count, impact-level deltas, new and resolved resources +- PowerShell 5.1 compatibility for JSON snapshot deserialization + ## Version 2.0.0 - Breaking Changes -- **Default behavior changed**: Now uses Az.Advisor PowerShell module by default instead of REST API -- **Connect-AzRetirementMonitor** now requires -UsingAPI switch and is only needed for API mode -- For default usage: Install Az.Advisor, run Connect-AzAccount, then Get-AzRetirementRecommendation -- For API usage: Run Connect-AzRetirementMonitor -UsingAPI, then Get-AzRetirementRecommendation -UseAPI -- Az.Advisor module is now recommended (checked at runtime) -- Provides full parity with Azure Advisor recommendations -- **PowerShell compatibility**: Now supports both PowerShell Core (7+) and Desktop (5.1) +- Default behavior changed: Now uses Az.Advisor PowerShell module by default instead of REST API +- Connect-AzRetirementMonitor now requires -UsingAPI switch and is only needed for API mode +- PowerShell compatibility: Supports both PowerShell Core (7+) and Desktop (5.1) '@ } } diff --git a/Private/Get-AzRetirementHistory.ps1 b/Private/Get-AzRetirementHistory.ps1 new file mode 100644 index 0000000..6ffff16 --- /dev/null +++ b/Private/Get-AzRetirementHistory.ps1 @@ -0,0 +1,35 @@ +function Get-AzRetirementHistory { + <# + .SYNOPSIS + Loads the change tracking history from a JSON file. + .DESCRIPTION + Reads the JSON file at the specified path and returns the deserialized history object. + Returns $null if the file does not exist or cannot be parsed. + .PARAMETER Path + Full path to the history JSON file. + .OUTPUTS + PSCustomObject + An object with Created (string) and Snapshots (array) properties, or $null if the file is missing or invalid. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Path + ) + + if (Test-Path -Path $Path) { + try { + $content = Get-Content -Path $Path -Raw -Encoding utf8 | ConvertFrom-Json + Write-Verbose "Loaded history from: $Path" + return $content + } + catch { + Write-Warning "Failed to load history from $Path : $_" + return $null + } + } + else { + Write-Verbose "No existing history file found at: $Path" + return $null + } +} diff --git a/Private/New-AzRetirementSnapshot.ps1 b/Private/New-AzRetirementSnapshot.ps1 new file mode 100644 index 0000000..f808f69 --- /dev/null +++ b/Private/New-AzRetirementSnapshot.ps1 @@ -0,0 +1,74 @@ +function New-AzRetirementSnapshot { + <# + .SYNOPSIS + Creates a snapshot of current retirement recommendations for change tracking. + .DESCRIPTION + Aggregates the provided recommendation objects into a lightweight snapshot containing + the timestamp, total count, per-impact-level counts, per-resource-type counts, and the + list of resource IDs. The snapshot is used by Show-AzRetirementComparison and + Save-AzRetirementHistory. + .PARAMETER Recommendations + Array of recommendation objects returned by Get-AzRetirementRecommendation. An empty + collection is permitted and produces a snapshot with zero counts. + .OUTPUTS + PSCustomObject + An object with properties: Timestamp (ISO 8601 string), TotalCount (int), + ImpactCounts (hashtable), ResourceTypeCounts (hashtable), ResourceIds (string[]). + #> + # SuppressMessageAttribute: this private function only constructs an in-memory object; + # no system state is changed, so ShouldProcess/ShouldContinue are not applicable. + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')] + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]]$Recommendations + ) + + # Count by impact level + $impactCounts = @{ + High = 0 + Medium = 0 + Low = 0 + } + + # Count by resource type + $resourceTypeCounts = @{} + + # Track resource IDs - using List for better performance + $resourceIds = [System.Collections.Generic.List[string]]::new() + + foreach ($rec in $Recommendations) { + # Count by impact + if ($rec.Impact) { + if ($impactCounts.ContainsKey($rec.Impact)) { + $impactCounts[$rec.Impact]++ + } + else { + Write-Warning "New-AzRetirementSnapshot: Unexpected Impact value '$($rec.Impact)' encountered. This value will be tracked but may not display correctly." + $impactCounts[$rec.Impact] = 1 + } + } + + # Count by resource type + if ($rec.ResourceType) { + if (-not $resourceTypeCounts.ContainsKey($rec.ResourceType)) { + $resourceTypeCounts[$rec.ResourceType] = 0 + } + $resourceTypeCounts[$rec.ResourceType]++ + } + + # Track resource IDs + if ($rec.ResourceId) { + $resourceIds.Add($rec.ResourceId) + } + } + + return [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = $Recommendations.Count + ImpactCounts = $impactCounts + ResourceTypeCounts = $resourceTypeCounts + ResourceIds = $resourceIds.ToArray() + } +} diff --git a/Private/Save-AzRetirementHistory.ps1 b/Private/Save-AzRetirementHistory.ps1 new file mode 100644 index 0000000..43e877b --- /dev/null +++ b/Private/Save-AzRetirementHistory.ps1 @@ -0,0 +1,31 @@ +function Save-AzRetirementHistory { + <# + .SYNOPSIS + Saves the change tracking history to a JSON file. + .DESCRIPTION + Serializes the history object to JSON (depth 10) and writes it to the specified path, + creating or overwriting the file. + .PARAMETER Path + Full path to the history JSON file. + .PARAMETER History + The history object (with Created and Snapshots properties) to persist. + .OUTPUTS + None. Writes a file to disk. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Path, + + [Parameter(Mandatory)] + [PSCustomObject]$History + ) + + try { + $History | ConvertTo-Json -Depth 10 | Set-Content -Path $Path -Encoding utf8 -Force + Write-Verbose "Saved history to: $Path" + } + catch { + Write-Warning "Failed to save history to $Path : $_" + } +} diff --git a/Private/Show-AzRetirementComparison.ps1 b/Private/Show-AzRetirementComparison.ps1 new file mode 100644 index 0000000..b4ffb82 --- /dev/null +++ b/Private/Show-AzRetirementComparison.ps1 @@ -0,0 +1,123 @@ +function Show-AzRetirementComparison { + <# + .SYNOPSIS + Displays a comparison between current and previous retirement snapshots. + .DESCRIPTION + Writes a formatted change-tracking summary to the console using Write-Host. + When a PreviousSnapshot is provided, shows deltas for total count, per-impact-level + counts, and lists new and resolved resource IDs. On the first run (no previous + snapshot) it displays the baseline counts. + .PARAMETER CurrentSnapshot + Snapshot object for the current run, as returned by New-AzRetirementSnapshot. + .PARAMETER PreviousSnapshot + Optional snapshot object from the previous run. When $null, the output indicates + this is the first tracked run. + .OUTPUTS + None. Writes formatted output to the host. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [PSCustomObject]$CurrentSnapshot, + + [Parameter()] + [PSCustomObject]$PreviousSnapshot + ) + + Write-Host "`n=== Azure Retirement Monitor - Change Tracking ===" -ForegroundColor Cyan + Write-Host "Current Run: $($CurrentSnapshot.Timestamp)" -ForegroundColor Gray + + if ($PreviousSnapshot) { + Write-Host "Previous Run: $($PreviousSnapshot.Timestamp)" -ForegroundColor Gray + + # Total count comparison + $totalChange = $CurrentSnapshot.TotalCount - $PreviousSnapshot.TotalCount + $totalChangeSymbol = if ($totalChange -gt 0) { "+" } elseif ($totalChange -lt 0) { "" } else { "" } + $totalChangeColor = if ($totalChange -gt 0) { "Red" } elseif ($totalChange -lt 0) { "Green" } else { "Gray" } + + Write-Host "`nTotal Recommendations: $($CurrentSnapshot.TotalCount) " -NoNewline + if ($totalChange -ne 0) { + Write-Host "($totalChangeSymbol$totalChange)" -ForegroundColor $totalChangeColor + } else { + Write-Host "(no change)" -ForegroundColor Gray + } + + # Impact level comparison + Write-Host "`nBy Impact Level:" -ForegroundColor Yellow + foreach ($impact in @('High', 'Medium', 'Low')) { + # ImpactCounts may be a hashtable (freshly created) or PSCustomObject (loaded from JSON). + # Use -is [hashtable] to select the correct access method for both cases. + $current = if ($CurrentSnapshot.ImpactCounts -is [hashtable]) { + if ($CurrentSnapshot.ImpactCounts.ContainsKey($impact)) { $CurrentSnapshot.ImpactCounts[$impact] } else { 0 } + } else { + $val = $CurrentSnapshot.ImpactCounts.$impact; if ($null -ne $val) { [int]$val } else { 0 } + } + $previous = if ($PreviousSnapshot.ImpactCounts -is [hashtable]) { + if ($PreviousSnapshot.ImpactCounts.ContainsKey($impact)) { $PreviousSnapshot.ImpactCounts[$impact] } else { 0 } + } else { + $val = $PreviousSnapshot.ImpactCounts.$impact; if ($null -ne $val) { [int]$val } else { 0 } + } + $change = $current - $previous + $changeSymbol = if ($change -gt 0) { "+" } elseif ($change -lt 0) { "" } else { "" } + $changeColor = if ($change -gt 0) { "Red" } elseif ($change -lt 0) { "Green" } else { "Gray" } + + Write-Host " $impact : $current " -NoNewline + if ($change -ne 0) { + Write-Host "($changeSymbol$change)" -ForegroundColor $changeColor + } else { + Write-Host "(no change)" -ForegroundColor Gray + } + } + + # Resource changes + $currentResourceIds = @($CurrentSnapshot.ResourceIds) + $previousResourceIds = @($PreviousSnapshot.ResourceIds) + + $newResources = $currentResourceIds | Where-Object { $_ -notin $previousResourceIds } + $resolvedResources = $previousResourceIds | Where-Object { $_ -notin $currentResourceIds } + + if ($newResources.Count -gt 0 -or $resolvedResources.Count -gt 0) { + Write-Host "`nResource Changes:" -ForegroundColor Yellow + + if ($newResources.Count -gt 0) { + Write-Host " New Issues: $($newResources.Count)" -ForegroundColor Red + foreach ($resourceId in $newResources) { + if (-not [string]::IsNullOrWhiteSpace($resourceId) -and $resourceId.Contains("/")) { + $resourceName = ($resourceId -split "/")[-1] + } else { + $resourceName = $resourceId + } + Write-Host " + $resourceName" -ForegroundColor Red + } + } + + if ($resolvedResources.Count -gt 0) { + Write-Host " Resolved: $($resolvedResources.Count)" -ForegroundColor Green + foreach ($resourceId in $resolvedResources) { + if (-not [string]::IsNullOrWhiteSpace($resourceId) -and $resourceId.Contains("/")) { + $resourceName = ($resourceId -split "/")[-1] + } else { + $resourceName = $resourceId + } + Write-Host " - $resourceName" -ForegroundColor Green + } + } + } + } + else { + Write-Host "`nThis is the first run with change tracking enabled." -ForegroundColor Yellow + Write-Host "Total Recommendations: $($CurrentSnapshot.TotalCount)" -ForegroundColor Gray + + Write-Host "`nBy Impact Level:" -ForegroundColor Yellow + foreach ($impact in @('High', 'Medium', 'Low')) { + $count = if ($CurrentSnapshot.ImpactCounts -is [hashtable]) { + if ($CurrentSnapshot.ImpactCounts.ContainsKey($impact)) { $CurrentSnapshot.ImpactCounts[$impact] } else { 0 } + } else { + $val = $CurrentSnapshot.ImpactCounts.$impact; if ($null -ne $val) { [int]$val } else { 0 } + } + Write-Host " $impact : $count" -ForegroundColor Gray + } + } + + Write-Host "`n=================================================" -ForegroundColor Cyan +} diff --git a/Public/Export-AzRetirementReport.ps1 b/Public/Export-AzRetirementReport.ps1 index 5a5c262..8d63256 100644 --- a/Public/Export-AzRetirementReport.ps1 +++ b/Public/Export-AzRetirementReport.ps1 @@ -12,6 +12,8 @@ Recommendation objects from Get-AzRetirementRecommendation (accepts pipeline inp File path for the exported report .PARAMETER Format Export format: CSV, JSON, or HTML (default: CSV) +.OUTPUTS +None. Creates a file at the specified OutputPath. .EXAMPLE Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.csv" -Format CSV Exports recommendations to CSV format @@ -23,6 +25,7 @@ Get-AzRetirementRecommendation -UseAPI | Export-AzRetirementReport -OutputPath " Exports API-sourced recommendations to JSON format #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] + [OutputType([void])] param( [Parameter(Mandatory, ValueFromPipeline)] [object[]]$Recommendations, diff --git a/Public/Get-AzRetirementMetadataItem.ps1 b/Public/Get-AzRetirementMetadataItem.ps1 index ab8106d..c55b067 100644 --- a/Public/Get-AzRetirementMetadataItem.ps1 +++ b/Public/Get-AzRetirementMetadataItem.ps1 @@ -5,8 +5,12 @@ Gets Azure Advisor recommendation metadata .DESCRIPTION Note: This function only works with the -UseAPI mode as Az.Advisor module does not expose metadata retrieval cmdlets. You must run Connect-AzRetirementMonitor -UsingAPI first. +.OUTPUTS +PSCustomObject[] +Each object contains: Name, Id, Type, DisplayName, DependsOn, ApplicableScenarios. #> [CmdletBinding()] + [OutputType([PSCustomObject[]])] param() if (-not $script:AccessToken) { diff --git a/Public/Get-AzRetirementRecommendation.ps1 b/Public/Get-AzRetirementRecommendation.ps1 index 0f4874d..65ce716 100644 --- a/Public/Get-AzRetirementRecommendation.ps1 +++ b/Public/Get-AzRetirementRecommendation.ps1 @@ -18,6 +18,15 @@ The API method requires: One or more subscription IDs to query. Defaults to all subscriptions. .PARAMETER UseAPI Use the Azure REST API instead of Az.Advisor PowerShell module. Requires Connect-AzRetirementMonitor first. +.PARAMETER EnableChangeTracking +Enable change tracking to monitor progress over time. Saves snapshots to a JSON file and displays comparison with previous run. +.PARAMETER ChangeTrackingPath +Path to the JSON file for storing change tracking history. Defaults to AzRetirementMonitor-History.json in the current working directory. +.OUTPUTS +PSCustomObject[] +Each object contains: SubscriptionId, ResourceId, ResourceName, ResourceType, ResourceGroup, +Category, Impact, Problem, Solution, Description, LastUpdated, IsRetirement, RecommendationId, +LearnMoreLink, ResourceLink. .EXAMPLE Get-AzRetirementRecommendation Gets all retirement recommendations using Az.Advisor module (default) @@ -27,19 +36,38 @@ Gets recommendations for a specific subscription using Az.Advisor module .EXAMPLE Get-AzRetirementRecommendation -UseAPI Gets recommendations using the REST API method +.EXAMPLE +Get-AzRetirementRecommendation -EnableChangeTracking +Gets recommendations and tracks changes over time, saving to default history file +.EXAMPLE +Get-AzRetirementRecommendation -EnableChangeTracking -ChangeTrackingPath "C:\Reports\retirement-history.json" +Gets recommendations and tracks changes using a custom history file path #> [CmdletBinding()] + [OutputType([PSCustomObject[]])] param( [Parameter(ValueFromPipeline)] [string[]]$SubscriptionId, [Parameter()] - [switch]$UseAPI + [switch]$UseAPI, + + [Parameter()] + [switch]$EnableChangeTracking, + + [Parameter()] + [string]$ChangeTrackingPath ) begin { $allRecommendations = [System.Collections.Generic.List[object]]::new() + # Resolve ChangeTrackingPath at invocation time so it always reflects the + # current working directory when the command is run, not when the module loaded. + if (-not $ChangeTrackingPath) { + $ChangeTrackingPath = Join-Path (Get-Location).Path 'AzRetirementMonitor-History.json' + } + if ($UseAPI) { # API mode - requires authentication via Connect-AzRetirementMonitor if (-not $script:AccessToken) { @@ -348,6 +376,52 @@ Gets recommendations using the REST API method } end { - return $allRecommendations.ToArray() + $recommendations = $allRecommendations.ToArray() + + # Handle change tracking if enabled + if ($EnableChangeTracking) { + # Load previous history + $history = Get-AzRetirementHistory -Path $ChangeTrackingPath + + # Create snapshot of current run + $currentSnapshot = New-AzRetirementSnapshot -Recommendations $recommendations + + # Get the previous snapshot if history exists. + # Wrap Snapshots in @() to normalise PS 5.1 behaviour where ConvertFrom-Json + # returns a bare PSCustomObject instead of a single-element array. + $previousSnapshot = if ($history -and $history.Snapshots) { + $snapshotsArray = @($history.Snapshots) + if ($snapshotsArray.Count -gt 0) { $snapshotsArray[-1] } else { $null } + } else { + $null + } + + # Display comparison + Show-AzRetirementComparison -CurrentSnapshot $currentSnapshot -PreviousSnapshot $previousSnapshot + + # Update history with new snapshot + if ($history) { + # Add new snapshot to existing history without repeatedly reallocating arrays + $snapshotsList = [System.Collections.Generic.List[object]]::new() + if ($history.Snapshots) { + # Wrap in @() so a single snapshot deserialised as a bare PSCustomObject + # by ConvertFrom-Json (PS 5.1) is still passed as an array to AddRange. + $snapshotsList.AddRange([object[]]@($history.Snapshots)) + } + $snapshotsList.Add($currentSnapshot) + $history.Snapshots = $snapshotsList.ToArray() + } else { + # Create new history object + $history = [PSCustomObject]@{ + Created = (Get-Date).ToString('o') + Snapshots = @($currentSnapshot) + } + } + + # Save updated history + Save-AzRetirementHistory -Path $ChangeTrackingPath -History $history + } + + return $recommendations } } \ No newline at end of file diff --git a/QUICKSTART.md b/QUICKSTART.md index 61231ba..bad66cd 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -1,4 +1,4 @@ -# AzRetirementMonitor v2.0 - Quick Start Guide +# AzRetirementMonitor v3.0 - Quick Start Guide **Compatible with PowerShell 5.1+ (Desktop and Core)** @@ -37,6 +37,18 @@ Get-AzRetirementRecommendation -UseAPI | Export-AzRetirementReport -OutputPath " Disconnect-AzRetirementMonitor ``` +## What's New in v3.0? + +### 📊 Change Tracking + +Track your progress in resolving retirement recommendations over time: + +- **`-EnableChangeTracking`** — saves a snapshot on each run and compares it to the previous one +- **`-ChangeTrackingPath`** — use a custom history file path instead of the default +- Console output shows total count deltas, impact-level changes, and new/resolved resources + +See the **Track Changes Over Time** section above for usage examples. + ## What Changed in v2.0? ### ✅ Default Method (NEW) @@ -83,6 +95,28 @@ Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.j Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.html" -Format HTML ``` +## Track Changes Over Time + +Use change tracking to monitor your progress in resolving retirement recommendations: + +```powershell +# First run — establishes the baseline +Get-AzRetirementRecommendation -EnableChangeTracking + +# Subsequent runs — shows what has been resolved and what is new +Get-AzRetirementRecommendation -EnableChangeTracking + +# Use a custom history file path +Get-AzRetirementRecommendation -EnableChangeTracking -ChangeTrackingPath "C:\Reports\retirement-history.json" +``` + +The history file is updated automatically on each run. The console output shows: + +- Total recommendation count with delta from the previous run +- Per-impact-level counts (High / Medium / Low) with deltas +- Newly appeared resource IDs +- Resource IDs that have been resolved since the last run + ## Troubleshooting ### "Az.Advisor module not available or not connected" diff --git a/README.md b/README.md index 2b06a64..2eb8ad1 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,18 @@ Azure services evolve constantly, with features, APIs, and entire services being **AzRetirementMonitor** helps you proactively identify Azure resources affected by upcoming retirements by querying Azure Advisor for service upgrade and retirement recommendations across all your subscriptions. This gives you time to plan migrations and upgrades before services are discontinued. +## 📊 Version 3.0.0 - Change Tracking + +**Version 3.0.0 adds change tracking to monitor your progress over time:** + +- **New parameter**: `-EnableChangeTracking` on `Get-AzRetirementRecommendation` saves a snapshot each run and compares it to the previous one +- **New parameter**: `-ChangeTrackingPath` to specify a custom history file location (defaults to `AzRetirementMonitor-History.json` in the current directory) +- Console output shows total count deltas, per-impact-level changes, and new/resolved resource IDs +- Full PowerShell 5.1 compatibility for snapshot JSON deserialization + ## 🚀 Version 2.0.0 - Breaking Changes -**Version 2.0.0 introduces a major change in how the module works:** +**Version 2.0.0 introduced a major change in how the module works:** - **Default behavior**: Now uses Az.Advisor PowerShell module (full parity with Azure Advisor) - **API mode**: Available via `-UseAPI` switch on Get-AzRetirementRecommendation @@ -173,12 +182,66 @@ Get-AzRetirementRecommendation -SubscriptionId "sub-id-1", "sub-id-2" # Use REST API instead (requires Connect-AzRetirementMonitor -UsingAPI first) Get-AzRetirementRecommendation -UseAPI + +# Enable change tracking to monitor progress over time +Get-AzRetirementRecommendation -EnableChangeTracking + +# Enable change tracking with custom history file path +Get-AzRetirementRecommendation -EnableChangeTracking -ChangeTrackingPath "C:\Reports\retirement-history.json" ``` **Parameters:** - `SubscriptionId` - One or more subscription IDs (defaults to all subscriptions) - `UseAPI` - Use REST API instead of Az.Advisor module +- `EnableChangeTracking` - Enable change tracking to monitor progress over time +- `ChangeTrackingPath` - Path to the JSON file for storing change tracking history (defaults to `AzRetirementMonitor-History.json` in the current working directory) + +#### Change Tracking + +The change tracking feature helps you monitor your progress in addressing retirement recommendations over time. When enabled: + +- **Stores snapshots** of each run in a JSON file +- **Displays comparison** with the previous run in the console +- **Tracks changes** in total count, impact levels, and individual resources +- **Highlights progress** by showing resolved and new issues + +**Example output:** + +``` +=== Azure Retirement Monitor - Change Tracking === +Current Run: 2024-01-15T10:30:00Z +Previous Run: 2024-01-10T09:00:00Z + +Total Recommendations: 15 (-3) + +By Impact Level: + High : 5 (-1) + Medium : 7 (-2) + Low : 3 (no change) + +Resource Changes: + New Issues: 2 + + new-vm-01 + + new-storage-01 + Resolved: 5 + - old-vm-01 + - old-storage-01 + - legacy-app-01 + - test-db-01 + - deprecated-api-01 + +================================================= +``` + +The history file contains minimal data needed for tracking: +- Timestamp of each run +- Total count of recommendations +- Counts by impact level (High, Medium, Low) +- Counts by resource type +- List of resource IDs (to track which issues are resolved or new) + +This allows you to easily see your progress in cleaning up retirement issues over time without storing full recommendation details. ### Connect-AzRetirementMonitor @@ -320,6 +383,47 @@ $recommendations | Format-Table ResourceName, Impact, Problem, Solution -AutoSiz $recommendations | Export-AzRetirementReport -OutputPath "retirement-report.html" -Format HTML ``` +### Change Tracking Workflow + +Use change tracking to monitor your progress over time: + +```powershell +# 1. Authenticate to Azure +Connect-AzAccount + +# 2. First run - establish baseline with change tracking +Get-AzRetirementRecommendation -EnableChangeTracking + +# Output shows: +# === Azure Retirement Monitor - Change Tracking === +# This is the first run with change tracking enabled. +# Total Recommendations: 25 +# ... + +# 3. Address some issues in your Azure environment +# (migrate resources, update configurations, etc.) + +# 4. Run again to see progress +Get-AzRetirementRecommendation -EnableChangeTracking + +# Output shows: +# === Azure Retirement Monitor - Change Tracking === +# Total Recommendations: 18 (-7) +# By Impact Level: +# High : 5 (-3) +# Medium : 10 (-4) +# Resource Changes: +# Resolved: 7 +# - old-vm-01 +# - legacy-storage-01 +# ... + +# 5. Track over time with custom path +Get-AzRetirementRecommendation -EnableChangeTracking -ChangeTrackingPath "C:\AzureReports\retirement-tracking.json" +``` + +The history file is automatically updated with each run, allowing you to track progress without managing multiple files. + ### Alternative Workflow (API Method) ```powershell diff --git a/Tests/AzRetirementMonitor.Tests.ps1 b/Tests/AzRetirementMonitor.Tests.ps1 index cb49862..07baa5f 100644 --- a/Tests/AzRetirementMonitor.Tests.ps1 +++ b/Tests/AzRetirementMonitor.Tests.ps1 @@ -679,4 +679,327 @@ Describe "Token Audience Validation" { $testResult = & $module { Test-AzRetirementMonitorToken } $testResult | Should -Be $false } +} + +Describe "Show-AzRetirementComparison Function Coverage" { + BeforeAll { + $module = Get-Module AzRetirementMonitor + } + + Context "First run scenario (no previous snapshot)" { + It "Should handle first run without errors" { + $currentSnapshot = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 5 + ImpactCounts = @{High = 2; Medium = 2; Low = 1} + ResourceIds = @() + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $currentSnapshot $null } | Should -Not -Throw + } + } + + Context "Comparison with previous snapshot" { + It "Should handle no changes between runs" { + $snapshot1 = [PSCustomObject]@{ + Timestamp = (Get-Date).AddDays(-1).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 1; Medium = 1; Low = 1} + ResourceIds = @('/sub/rg/vm1', '/sub/rg/vm2', '/sub/rg/vm3') + } + + $snapshot2 = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 1; Medium = 1; Low = 1} + ResourceIds = @('/sub/rg/vm1', '/sub/rg/vm2', '/sub/rg/vm3') + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $snapshot2 $snapshot1 } | Should -Not -Throw + } + + It "Should handle changes in counts" { + $snapshot1 = [PSCustomObject]@{ + Timestamp = (Get-Date).AddDays(-1).ToString('o') + TotalCount = 5 + ImpactCounts = @{High = 2; Medium = 2; Low = 1} + ResourceIds = @() + } + + $snapshot2 = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 1; Medium = 1; Low = 1} + ResourceIds = @() + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $snapshot2 $snapshot1 } | Should -Not -Throw + } + + It "Should handle new resources" { + $snapshot1 = [PSCustomObject]@{ + Timestamp = (Get-Date).AddDays(-1).ToString('o') + TotalCount = 2 + ImpactCounts = @{High = 1; Medium = 1; Low = 0} + ResourceIds = @('/sub/rg/vm1', '/sub/rg/vm2') + } + + $snapshot2 = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 2; Medium = 1; Low = 0} + ResourceIds = @('/sub/rg/vm1', '/sub/rg/vm2', '/sub/rg/vm3') + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $snapshot2 $snapshot1 } | Should -Not -Throw + } + + It "Should handle resolved resources" { + $snapshot1 = [PSCustomObject]@{ + Timestamp = (Get-Date).AddDays(-1).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 2; Medium = 1; Low = 0} + ResourceIds = @('/sub/rg/vm1', '/sub/rg/vm2', '/sub/rg/vm3') + } + + $snapshot2 = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 1 + ImpactCounts = @{High = 1; Medium = 0; Low = 0} + ResourceIds = @('/sub/rg/vm1') + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $snapshot2 $snapshot1 } | Should -Not -Throw + } + + It "Should handle missing impact keys in previous snapshot" { + $snapshot1 = [PSCustomObject]@{ + Timestamp = (Get-Date).AddDays(-1).ToString('o') + TotalCount = 2 + ImpactCounts = @{High = 2} # Missing Medium and Low + ResourceIds = @() + } + + $snapshot2 = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 1; Medium = 1; Low = 1} + ResourceIds = @() + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $snapshot2 $snapshot1 } | Should -Not -Throw + } + + It "Should handle edge case resource IDs" { + $snapshot1 = [PSCustomObject]@{ + Timestamp = (Get-Date).AddDays(-1).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 1; Medium = 1; Low = 1} + ResourceIds = @('/sub/rg/vm1', 'simple-name', '') + } + + $snapshot2 = [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 2 + ImpactCounts = @{High = 1; Medium = 1; Low = 0} + ResourceIds = @('/sub/rg/vm1', 'another-simple') + } + + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $snapshot2 $snapshot1 } | Should -Not -Throw + } + } +} + +Describe "Change Tracking Feature" { + BeforeAll { + # Create a temporary directory for test outputs + $script:TestTrackingDir = Join-Path ([System.IO.Path]::GetTempPath()) "AzRetirementChangeTracking_$([guid]::NewGuid())" + New-Item -Path $script:TestTrackingDir -ItemType Directory -Force | Out-Null + } + + AfterAll { + # Clean up test output directory + if (Test-Path $script:TestTrackingDir) { + Remove-Item -Path $script:TestTrackingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + Context "Parameter Validation" { + It "Should have EnableChangeTracking parameter" { + $cmd = Get-Command Get-AzRetirementRecommendation + $cmd.Parameters.ContainsKey('EnableChangeTracking') | Should -Be $true + } + + It "Should have ChangeTrackingPath parameter" { + $cmd = Get-Command Get-AzRetirementRecommendation + $cmd.Parameters.ContainsKey('ChangeTrackingPath') | Should -Be $true + } + + It "ChangeTrackingPath should be an optional string parameter" { + $cmd = Get-Command Get-AzRetirementRecommendation + $param = $cmd.Parameters['ChangeTrackingPath'] + $param.Attributes.TypeId.Name | Should -Contain 'ParameterAttribute' + $param.ParameterType | Should -Be ([string]) + } + + It "ChangeTrackingPath should resolve to current working directory when not specified" { + # The default is resolved at invocation time inside the begin block, not at module + # load time, so it always reflects the caller's current working directory. + $expectedPath = Join-Path (Get-Location).Path 'AzRetirementMonitor-History.json' + $cmd = Get-Command Get-AzRetirementRecommendation + # Confirm no static default is baked into the parameter metadata + $param = $cmd.Parameters['ChangeTrackingPath'] + $param.DefaultValue | Should -BeNullOrEmpty + # Confirm the expected runtime path would be constructed correctly + $expectedPath | Should -Match 'AzRetirementMonitor-History\.json$' + $expectedPath | Should -BeLike "$(Get-Location)*" + } + } + + Context "Helper Functions" { + It "New-AzRetirementSnapshot should create a snapshot" { + $testRecs = @( + [PSCustomObject]@{ + ResourceId = "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/virtualMachines/vm1" + ResourceType = "Microsoft.Compute/virtualMachines" + Impact = "High" + }, + [PSCustomObject]@{ + ResourceId = "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Storage/storageAccounts/sa1" + ResourceType = "Microsoft.Storage/storageAccounts" + Impact = "Medium" + } + ) + + $module = Get-Module AzRetirementMonitor + $snapshot = & $module { param($recs) New-AzRetirementSnapshot -Recommendations $recs } $testRecs + + $snapshot.TotalCount | Should -Be 2 + $snapshot.ImpactCounts.High | Should -Be 1 + $snapshot.ImpactCounts.Medium | Should -Be 1 + $snapshot.ResourceIds.Count | Should -Be 2 + } + + It "New-AzRetirementSnapshot should handle empty recommendations" { + $module = Get-Module AzRetirementMonitor + $snapshot = & $module { New-AzRetirementSnapshot -Recommendations @() } + + $snapshot.TotalCount | Should -Be 0 + $snapshot.ImpactCounts.High | Should -Be 0 + } + + It "Save-AzRetirementHistory should create a JSON file" { + $testPath = Join-Path $script:TestTrackingDir "test-history.json" + $testHistory = [PSCustomObject]@{ + Created = (Get-Date).ToString('o') + Snapshots = @( + [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 5 + ImpactCounts = @{High = 2; Medium = 2; Low = 1} + ResourceTypeCounts = @{} + ResourceIds = @() + } + ) + } + + $module = Get-Module AzRetirementMonitor + & $module { param($p, $h) Save-AzRetirementHistory -Path $p -History $h } $testPath $testHistory + + Test-Path $testPath | Should -Be $true + + $saved = Get-Content $testPath -Raw | ConvertFrom-Json + $saved.Snapshots[0].TotalCount | Should -Be 5 + } + + It "Get-AzRetirementHistory should load existing history" { + $testPath = Join-Path $script:TestTrackingDir "test-load-history.json" + $testHistory = [PSCustomObject]@{ + Created = (Get-Date).ToString('o') + Snapshots = @( + [PSCustomObject]@{ + Timestamp = (Get-Date).ToString('o') + TotalCount = 3 + ImpactCounts = @{High = 1; Medium = 1; Low = 1} + ResourceTypeCounts = @{} + ResourceIds = @() + } + ) + } + + $testHistory | ConvertTo-Json -Depth 10 | Set-Content -Path $testPath + + $module = Get-Module AzRetirementMonitor + $loaded = & $module { param($p) Get-AzRetirementHistory -Path $p } $testPath + + $loaded | Should -Not -BeNull + $loaded.Snapshots[0].TotalCount | Should -Be 3 + } + + It "Get-AzRetirementHistory should return null for non-existent file" { + $testPath = Join-Path $script:TestTrackingDir "non-existent.json" + + $module = Get-Module AzRetirementMonitor + $loaded = & $module { param($p) Get-AzRetirementHistory -Path $p } $testPath + + $loaded | Should -BeNull + } + + It "Show-AzRetirementComparison should handle ImpactCounts as PSCustomObject after JSON deserialization" { + # Regression test: ImpactCounts loaded from JSON becomes PSCustomObject, not hashtable. + # The comparison should not throw or return wrong values. + $testPath = Join-Path $script:TestTrackingDir "roundtrip-impactcounts-test.json" + $module = Get-Module AzRetirementMonitor + + # Build a snapshot and persist it + $testRecs = @( + [PSCustomObject]@{ ResourceId = '/sub/rg/vm1'; ResourceType = 'Microsoft.Compute/virtualMachines'; Impact = 'High' }, + [PSCustomObject]@{ ResourceId = '/sub/rg/sa1'; ResourceType = 'Microsoft.Storage/storageAccounts'; Impact = 'Medium' } + ) + $snapshot = & $module { param($r) New-AzRetirementSnapshot -Recommendations $r } $testRecs + $historyToSave = [PSCustomObject]@{ + Created = (Get-Date).ToString('o') + Snapshots = @($snapshot) + } + & $module { param($p, $h) Save-AzRetirementHistory -Path $p -History $h } $testPath $historyToSave + + # Reload; ImpactCounts is now PSCustomObject, not hashtable + $loadedHistory = & $module { param($p) Get-AzRetirementHistory -Path $p } $testPath + $deserializedSnapshot = @($loadedHistory.Snapshots)[0] + + # Create a fresh current snapshot (ImpactCounts is a hashtable) + $newSnapshot = & $module { New-AzRetirementSnapshot -Recommendations @() } + + # Must not throw even though PreviousSnapshot.ImpactCounts is a PSCustomObject + { & $module { param($c, $p) Show-AzRetirementComparison -CurrentSnapshot $c -PreviousSnapshot $p } $newSnapshot $deserializedSnapshot } | Should -Not -Throw + } + + It "Should correctly read previous snapshot from single-snapshot history file" { + # Regression test: PS 5.1 ConvertFrom-Json may deserialise a single-element JSON array + # as a bare object rather than an array. @() normalisation must handle this. + $testPath = Join-Path $script:TestTrackingDir "single-snapshot-roundtrip-test.json" + $module = Get-Module AzRetirementMonitor + + $testRecs = @( + [PSCustomObject]@{ ResourceId = '/sub/rg/vm1'; ResourceType = 'Microsoft.Compute/virtualMachines'; Impact = 'High' } + ) + $snapshot = & $module { param($r) New-AzRetirementSnapshot -Recommendations $r } $testRecs + $historyToSave = [PSCustomObject]@{ + Created = (Get-Date).ToString('o') + Snapshots = @($snapshot) + } + & $module { param($p, $h) Save-AzRetirementHistory -Path $p -History $h } $testPath $historyToSave + + # Reload history + $loadedHistory = & $module { param($p) Get-AzRetirementHistory -Path $p } $testPath + + # @() normalisation must yield exactly one snapshot + $snapshotsArray = @($loadedHistory.Snapshots) + $snapshotsArray.Count | Should -Be 1 + $snapshotsArray[0].TotalCount | Should -Be 1 + + # Accessing the last element must work correctly + $snapshotsArray[-1].TotalCount | Should -Be 1 + } + } } \ No newline at end of file