-
Notifications
You must be signed in to change notification settings - Fork 0
Add opt-in change tracking for retirement recommendations #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b64e5fc
4d76992
8426cb1
1cd1d44
fcda547
6fc1f74
5e0294f
998ba5c
d30d2ff
b163bde
48c1f23
afd5a96
95dabe1
e63512f
750a499
7cb8c66
8601b47
10e9982
66af282
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| .DS_Store | ||
| AzRetirementMonitor-History.json |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 : $_" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
Comment on lines
+75
to
+78
|
||||||||||||||||||||||||||
| $newResources = $currentResourceIds | Where-Object { $_ -notin $previousResourceIds } | |
| $resolvedResources = $previousResourceIds | Where-Object { $_ -notin $currentResourceIds } | |
| # Use hash sets for efficient membership checks instead of O(n*m) -notin scans | |
| $previousIdSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$previousResourceIds) | |
| $currentIdSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$currentResourceIds) | |
| $newResources = $currentResourceIds | Where-Object { -not $previousIdSet.Contains([string]$_) } | |
| $resolvedResources = $previousResourceIds | Where-Object { -not $currentIdSet.Contains([string]$_) } | |
Copilot
AI
Feb 15, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a resourceId is an empty string or contains only a single segment without slashes, the split operation will still work but may not produce the expected output. Consider adding a null/empty check before splitting, or handling edge cases where the resource ID format might be unexpected. This would make the function more robust against malformed data in the history file.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot add null or empty checks based on the comment above
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Already implemented in commit d30d2ff. Added null/empty checks using [string]::IsNullOrWhiteSpace() and .Contains("/") before splitting resourceId on lines 67 and 79 to handle edge cases gracefully.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Save-AzRetirementHistoryuses the verbSave, which is not an approved PowerShell verb and is likely to be flagged by PSScriptAnalyzer (PSUseApprovedVerbs) in CI. Consider renaming to an approved verb (e.g.,Set-AzRetirementHistoryorExport-AzRetirementHistory) and updating call sites/tests accordingly.