Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.DS_Store
AzRetirementMonitor-History.json
19 changes: 11 additions & 8 deletions AzRetirementMonitor.psd1
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
'@
}
}
Expand Down
35 changes: 35 additions & 0 deletions Private/Get-AzRetirementHistory.ps1
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
}
}
74 changes: 74 additions & 0 deletions Private/New-AzRetirementSnapshot.ps1
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()
}
}
31 changes: 31 additions & 0 deletions Private/Save-AzRetirementHistory.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
function Save-AzRetirementHistory {
<#
.SYNOPSIS
Comment on lines +1 to +3
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Save-AzRetirementHistory uses the verb Save, 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-AzRetirementHistory or Export-AzRetirementHistory) and updating call sites/tests accordingly.

Copilot uses AI. Check for mistakes.
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 : $_"
}
}
123 changes: 123 additions & 0 deletions Private/Show-AzRetirementComparison.ps1
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
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new/resolved resource calculations use Where-Object { $_ -notin $otherList }, which does an O(n*m) membership scan and can become a hot spot when many resource IDs are tracked. Consider converting one side to a HashSet[string] (or a hashtable lookup) and doing O(1) membership checks to keep comparisons fast as history grows.

Suggested change
$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 uses AI. Check for mistakes.
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
Comment on lines +84 to +90
Copy link

Copilot AI Feb 15, 2026

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.

Copilot uses AI. Check for mistakes.
Copy link
Owner

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

Copy link
Contributor Author

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.

}
}

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
}
3 changes: 3 additions & 0 deletions Public/Export-AzRetirementReport.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions Public/Get-AzRetirementMetadataItem.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading