Skip to content
Merged
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
16 changes: 13 additions & 3 deletions AzRetirementMonitor.psd1
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
@{
RootModule = 'AzRetirementMonitor.psm1'
ModuleVersion = '1.2.1'
ModuleVersion = '2.0.0'
GUID = '6775bae9-a3ec-43de-abd9-14308dd345c4'
Author = 'Corey Callaway'
CompanyName = 'Independent'
Description = 'A PowerShell module for identifying and monitoring Azure service retirements and deprecation notices of Azure services in your subscriptions.'
PowerShellVersion = '7.0'
CompatiblePSEditions = @('Core')
PowerShellVersion = '5.1'
CompatiblePSEditions = @('Core', 'Desktop')

FunctionsToExport = @(
'Connect-AzRetirementMonitor',
Expand All @@ -21,6 +21,16 @@
Tags = @('Azure', 'Advisor', 'Retirement', 'Monitoring')
LicenseUri = 'https://github.com/cocallaw/AzRetirementMonitor/blob/main/LICENSE'
ProjectUri = 'https://github.com/cocallaw/AzRetirementMonitor'
ReleaseNotes = @'
## 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)
'@
}
}
}
52 changes: 52 additions & 0 deletions Private/Test-AzAdvisorSession.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
function Test-AzAdvisorSession {
<#
.SYNOPSIS
Tests if Az.Advisor module is available and an Azure PowerShell session is active
.DESCRIPTION
Validates that:
1. Az.Advisor module is installed and can be imported
2. An active Azure PowerShell context exists (user is connected via Connect-AzAccount)

Returns $true if both conditions are met, $false otherwise.
Writes informative verbose messages to help troubleshoot connection issues.
.OUTPUTS
System.Boolean
#>
[CmdletBinding()]
[OutputType([bool])]
param()

# Check if Az.Advisor module is available
if (-not (Get-Module -ListAvailable -Name Az.Advisor)) {
Write-Verbose "Az.Advisor module is not installed. Install it with: Install-Module -Name Az.Advisor"
return $false
}

# Try to import the module
try {
Import-Module Az.Advisor -ErrorAction Stop
Write-Verbose "Az.Advisor module loaded successfully"
}
catch {
Write-Verbose "Failed to import Az.Advisor module: $_"
return $false
}

# Check if there's an active Azure PowerShell context
try {
$context = Get-AzContext -ErrorAction Stop

if (-not $context) {
Write-Verbose "No active Azure PowerShell context. Run Connect-AzAccount first."
return $false
}

Write-Verbose "Active Azure context found: $($context.Account.Id) in subscription $($context.Subscription.Name)"
return $true
}
catch {
Write-Verbose "Failed to get Azure context: $_"
Write-Verbose "Run Connect-AzAccount to establish an Azure PowerShell session"
return $false
}
}
32 changes: 25 additions & 7 deletions Public/Connect-AzRetirementMonitor.ps1
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
function Connect-AzRetirementMonitor {
<#
.SYNOPSIS
Authenticates to Azure and stores an access token
Authenticates to Azure and stores an access token for REST API access
.DESCRIPTION
⚠️ IMPORTANT: This command is ONLY needed when using Get-AzRetirementRecommendation with the -UseAPI switch.

By default, Get-AzRetirementRecommendation uses the Az.Advisor PowerShell module, which does NOT require
this connection command. Simply use Connect-AzAccount and then call Get-AzRetirementRecommendation.

This command is only for API-based access and requires the -UsingAPI switch to proceed.

Uses Azure CLI (default) or Az.Accounts to authenticate and obtain an access token
scoped to https://management.azure.com for read-only Azure Advisor API access.

Expand All @@ -15,29 +22,38 @@ Required RBAC permissions: Reader role at subscription or resource group scope

The token is stored in a module-scoped variable for the duration of the PowerShell session
and is validated for proper audience (https://management.azure.com) before use.
.PARAMETER UsingAPI
Required switch to confirm you intend to use API-based access. This prevents accidentally
connecting when using the default Az.Advisor module method.
.PARAMETER UseAzCLI
Use Azure CLI (az) for authentication. This is the default.
Use Azure CLI (az) for authentication. This is the default for API access.
.PARAMETER UseAzPowerShell
Use Az.Accounts PowerShell module for authentication.
.EXAMPLE
Connect-AzRetirementMonitor
Connects using Azure CLI (default method)
Connect-AzRetirementMonitor -UsingAPI
Connects using Azure CLI for API-based access
.EXAMPLE
Connect-AzRetirementMonitor -UseAzPowerShell
Connects using Az.Accounts PowerShell module
Connect-AzRetirementMonitor -UsingAPI -UseAzPowerShell
Connects using Az.Accounts PowerShell module for API-based access
.OUTPUTS
None. Displays a success message when authentication completes.
#>
[CmdletBinding(DefaultParameterSetName = 'AzCLI')]
[OutputType([void])]
param(
[Parameter(Mandatory)]
[switch]$UsingAPI,

[Parameter(ParameterSetName = 'AzCLI')]
[switch]$UseAzCLI,

[Parameter(ParameterSetName = 'AzPS')]
[switch]$UseAzPowerShell
)

Write-Host "Connecting for API-based access..."
Write-Verbose "This connection is only needed when using Get-AzRetirementRecommendation -UseAPI"

try {
if ($UseAzPowerShell) {
if (-not (Get-Module -ListAvailable -Name Az.Accounts)) {
Expand Down Expand Up @@ -79,9 +95,11 @@ None. Displays a success message when authentication completes.
--output tsv
}

Write-Host "Authenticated to Azure successfully"
Write-Host "Authenticated to Azure successfully for API access"
Write-Verbose "Token is scoped to https://management.azure.com for Azure Resource Manager API access"
Write-Verbose "This module only uses read-only operations: Microsoft.Advisor/recommendations/read and Microsoft.Advisor/metadata/read"
Write-Host ""
Write-Host "To use API mode, run: Get-AzRetirementRecommendation -UseAPI" -ForegroundColor Cyan
}
catch {
Write-Error "Authentication failed: $_"
Expand Down
85 changes: 80 additions & 5 deletions Public/Export-AzRetirementReport.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@ function Export-AzRetirementReport {
<#
.SYNOPSIS
Exports retirement recommendations to CSV, JSON, or HTML
.DESCRIPTION
Exports retirement recommendations retrieved from Get-AzRetirementRecommendation to various
formats for reporting and analysis. Works with recommendations from both the default Az.Advisor
method and the API method.
.PARAMETER Recommendations
Recommendation objects from Get-AzRetirementRecommendation (accepts pipeline input)
.PARAMETER OutputPath
File path for the exported report
.PARAMETER Format
Export format: CSV, JSON, or HTML (default: CSV)
.EXAMPLE
Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.csv" -Format CSV
Exports recommendations to CSV format
.EXAMPLE
Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.html" -Format HTML
Exports recommendations to HTML format
.EXAMPLE
Get-AzRetirementRecommendation -UseAPI | Export-AzRetirementReport -OutputPath "report.json" -Format JSON
Exports API-sourced recommendations to JSON format
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')]
param(
Expand All @@ -28,12 +47,67 @@ Exports retirement recommendations to CSV, JSON, or HTML
return
}

# Transform data to use Description in Solution column for Az.Advisor mode
# (when Problem and Solution are the same, Description usually has better info)
# This transformation is used for CSV, JSON, and HTML formats
$transformedRecs = $allRecs | ForEach-Object {
# Use Description if Problem == Solution and Description exists
# This indicates Az.Advisor mode where generic text is duplicated
$solutionValue = if ($_.Problem -eq $_.Solution -and $_.Description) {
$_.Description
} else {
$_.Solution
}

# Create new object with properly converted properties
# This ensures all properties are strings (not arrays) for proper export
[PSCustomObject]@{
SubscriptionId = $_.SubscriptionId
ResourceId = $_.ResourceId
ResourceName = $_.ResourceName
ResourceType = $_.ResourceType
ResourceGroup = $_.ResourceGroup
Category = $_.Category
Impact = $_.Impact
Problem = $_.Problem
Description = $_.Description
LastUpdated = $_.LastUpdated
IsRetirement = $_.IsRetirement
RecommendationId = $_.RecommendationId
LearnMoreLink = $_.LearnMoreLink
ResourceLink = $_.ResourceLink
Solution = $solutionValue
}
}

switch ($Format) {
"CSV" {
$allRecs | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding utf8
# Sanitize potential formula injections for CSV consumers (e.g., Excel)
$safeRecs = $transformedRecs | ForEach-Object {
# Create new object with sanitized values
$rec = $_
[PSCustomObject]@{
SubscriptionId = if ($rec.SubscriptionId -is [string] -and $rec.SubscriptionId.Length -gt 0 -and $rec.SubscriptionId[0] -in '=','+','-','@') { "'" + $rec.SubscriptionId } else { $rec.SubscriptionId }
ResourceId = if ($rec.ResourceId -is [string] -and $rec.ResourceId.Length -gt 0 -and $rec.ResourceId[0] -in '=','+','-','@') { "'" + $rec.ResourceId } else { $rec.ResourceId }
ResourceName = if ($rec.ResourceName -is [string] -and $rec.ResourceName.Length -gt 0 -and $rec.ResourceName[0] -in '=','+','-','@') { "'" + $rec.ResourceName } else { $rec.ResourceName }
ResourceType = if ($rec.ResourceType -is [string] -and $rec.ResourceType.Length -gt 0 -and $rec.ResourceType[0] -in '=','+','-','@') { "'" + $rec.ResourceType } else { $rec.ResourceType }
ResourceGroup = if ($rec.ResourceGroup -is [string] -and $rec.ResourceGroup.Length -gt 0 -and $rec.ResourceGroup[0] -in '=','+','-','@') { "'" + $rec.ResourceGroup } else { $rec.ResourceGroup }
Category = if ($rec.Category -is [string] -and $rec.Category.Length -gt 0 -and $rec.Category[0] -in '=','+','-','@') { "'" + $rec.Category } else { $rec.Category }
Impact = if ($rec.Impact -is [string] -and $rec.Impact.Length -gt 0 -and $rec.Impact[0] -in '=','+','-','@') { "'" + $rec.Impact } else { $rec.Impact }
Problem = if ($rec.Problem -is [string] -and $rec.Problem.Length -gt 0 -and $rec.Problem[0] -in '=','+','-','@') { "'" + $rec.Problem } else { $rec.Problem }
Description = if ($rec.Description -is [string] -and $rec.Description.Length -gt 0 -and $rec.Description[0] -in '=','+','-','@') { "'" + $rec.Description } else { $rec.Description }
LastUpdated = if ($rec.LastUpdated -is [string] -and $rec.LastUpdated.Length -gt 0 -and $rec.LastUpdated[0] -in '=','+','-','@') { "'" + $rec.LastUpdated } else { $rec.LastUpdated }
IsRetirement = if ($rec.IsRetirement -is [string] -and $rec.IsRetirement.Length -gt 0 -and $rec.IsRetirement[0] -in '=','+','-','@') { "'" + $rec.IsRetirement } else { $rec.IsRetirement }
RecommendationId = if ($rec.RecommendationId -is [string] -and $rec.RecommendationId.Length -gt 0 -and $rec.RecommendationId[0] -in '=','+','-','@') { "'" + $rec.RecommendationId } else { $rec.RecommendationId }
LearnMoreLink = if ($rec.LearnMoreLink -is [string] -and $rec.LearnMoreLink.Length -gt 0 -and $rec.LearnMoreLink[0] -in '=','+','-','@') { "'" + $rec.LearnMoreLink } else { $rec.LearnMoreLink }
ResourceLink = if ($rec.ResourceLink -is [string] -and $rec.ResourceLink.Length -gt 0 -and $rec.ResourceLink[0] -in '=','+','-','@') { "'" + $rec.ResourceLink } else { $rec.ResourceLink }
Solution = if ($rec.Solution -is [string] -and $rec.Solution.Length -gt 0 -and $rec.Solution[0] -in '=','+','-','@') { "'" + $rec.Solution } else { $rec.Solution }
}
}
$safeRecs | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding utf8
}
"JSON" {
$allRecs | ConvertTo-Json -Depth 10 | Out-File $OutputPath -Encoding utf8
$transformedRecs | ConvertTo-Json -Depth 10 | Out-File $OutputPath -Encoding utf8
}
"HTML" {
# Helper function to escape HTML to prevent XSS
Expand All @@ -45,7 +119,8 @@ Exports retirement recommendations to CSV, JSON, or HTML
return [System.Net.WebUtility]::HtmlEncode($Text)
}

$generatedTime = (Get-Date -AsUTC).ToString("yyyy-MM-dd HH:mm:ss 'UTC'")
# PowerShell 5.1 compatible UTC time (Get-Date -AsUTC is PS 7+ only)
$generatedTime = [DateTime]::UtcNow.ToString("yyyy-MM-dd HH:mm:ss 'UTC'")
$totalCount = $allRecs.Count

# Define CSS for professional styling
Expand Down Expand Up @@ -177,7 +252,7 @@ Exports retirement recommendations to CSV, JSON, or HTML
<th>Resource Name</th>
<th>Resource Type</th>
<th>Problem</th>
<th>Solution</th>
<th>Description</th>
<th>Resource Group</th>
<th>Subscription ID</th>
<th>Resource Link</th>
Expand All @@ -188,7 +263,7 @@ Exports retirement recommendations to CSV, JSON, or HTML
"@

# Add table rows - collect in array for better performance
$tableRows = foreach ($rec in $allRecs) {
$tableRows = foreach ($rec in $transformedRecs) {
# HTML encode all user-provided data to prevent XSS
$encodedResourceName = ConvertTo-HtmlEncoded $rec.ResourceName
$encodedResourceType = ConvertTo-HtmlEncoded $rec.ResourceType
Expand Down
7 changes: 5 additions & 2 deletions Public/Get-AzRetirementMetadataItem.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ function Get-AzRetirementMetadataItem {
<#
.SYNOPSIS
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.
#>
[CmdletBinding()]
param()

if (-not $script:AccessToken) {
throw "Not authenticated. Run Connect-AzRetirementMonitor first."
throw "Not authenticated. Run Connect-AzRetirementMonitor -UsingAPI first. Note: This function requires API access as Az.Advisor module does not expose metadata cmdlets."
}

if (-not (Test-AzRetirementMonitorToken)) {
throw "Access token has expired. Run Connect-AzRetirementMonitor again."
throw "Access token has expired. Run Connect-AzRetirementMonitor -UsingAPI again."
}

$headers = @{
Expand Down
Loading