diff --git a/AzRetirementMonitor.psd1 b/AzRetirementMonitor.psd1
index 53c3c1f..5b40e5e 100644
--- a/AzRetirementMonitor.psd1
+++ b/AzRetirementMonitor.psd1
@@ -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',
@@ -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)
+'@
}
}
}
\ No newline at end of file
diff --git a/Private/Test-AzAdvisorSession.ps1 b/Private/Test-AzAdvisorSession.ps1
new file mode 100644
index 0000000..970f31f
--- /dev/null
+++ b/Private/Test-AzAdvisorSession.ps1
@@ -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
+ }
+}
diff --git a/Public/Connect-AzRetirementMonitor.ps1 b/Public/Connect-AzRetirementMonitor.ps1
index a0194da..1512655 100644
--- a/Public/Connect-AzRetirementMonitor.ps1
+++ b/Public/Connect-AzRetirementMonitor.ps1
@@ -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.
@@ -15,22 +22,28 @@ 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,
@@ -38,6 +51,9 @@ None. Displays a success message when authentication completes.
[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)) {
@@ -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: $_"
diff --git a/Public/Export-AzRetirementReport.ps1 b/Public/Export-AzRetirementReport.ps1
index f42ab22..5a5c262 100644
--- a/Public/Export-AzRetirementReport.ps1
+++ b/Public/Export-AzRetirementReport.ps1
@@ -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(
@@ -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
@@ -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
@@ -177,7 +252,7 @@ Exports retirement recommendations to CSV, JSON, or HTML
Resource Name |
Resource Type |
Problem |
- Solution |
+ Description |
Resource Group |
Subscription ID |
Resource Link |
@@ -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
diff --git a/Public/Get-AzRetirementMetadataItem.ps1 b/Public/Get-AzRetirementMetadataItem.ps1
index 84f520e..ab8106d 100644
--- a/Public/Get-AzRetirementMetadataItem.ps1
+++ b/Public/Get-AzRetirementMetadataItem.ps1
@@ -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 = @{
diff --git a/Public/Get-AzRetirementRecommendation.ps1 b/Public/Get-AzRetirementRecommendation.ps1
index 2964992..0f4874d 100644
--- a/Public/Get-AzRetirementRecommendation.ps1
+++ b/Public/Get-AzRetirementRecommendation.ps1
@@ -2,61 +2,273 @@ function Get-AzRetirementRecommendation {
<#
.SYNOPSIS
Gets Azure service retirement recommendations for HighAvailability category and ServiceUpgradeAndRetirement subcategory
+.DESCRIPTION
+By default, uses the Az.Advisor PowerShell module to retrieve recommendations. This provides
+complete parity with Azure Advisor data. Optionally, use -UseAPI to query the REST API directly
+(requires Connect-AzRetirementMonitor first).
+
+The Az.Advisor module method requires:
+- Az.Advisor module installed
+- Active Azure PowerShell session (Connect-AzAccount)
+
+The API method requires:
+- Connect-AzRetirementMonitor called first
+- Valid access token
+.PARAMETER SubscriptionId
+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.
+.EXAMPLE
+Get-AzRetirementRecommendation
+Gets all retirement recommendations using Az.Advisor module (default)
+.EXAMPLE
+Get-AzRetirementRecommendation -SubscriptionId "12345678-1234-1234-1234-123456789012"
+Gets recommendations for a specific subscription using Az.Advisor module
+.EXAMPLE
+Get-AzRetirementRecommendation -UseAPI
+Gets recommendations using the REST API method
#>
[CmdletBinding()]
param(
[Parameter(ValueFromPipeline)]
- [string[]]$SubscriptionId
+ [string[]]$SubscriptionId,
+
+ [Parameter()]
+ [switch]$UseAPI
)
begin {
- if (-not $script:AccessToken) {
- throw "Not authenticated. Run Connect-AzRetirementMonitor first."
- }
+ $allRecommendations = [System.Collections.Generic.List[object]]::new()
- if (-not (Test-AzRetirementMonitorToken)) {
- throw "Access token has expired. Run Connect-AzRetirementMonitor again."
- }
+ if ($UseAPI) {
+ # API mode - requires authentication via Connect-AzRetirementMonitor
+ if (-not $script:AccessToken) {
+ throw "Not authenticated. Run Connect-AzRetirementMonitor -UsingAPI first."
+ }
- $headers = @{
- Authorization = "Bearer $script:AccessToken"
- "Content-Type" = "application/json"
- }
+ if (-not (Test-AzRetirementMonitorToken)) {
+ throw "Access token has expired. Run Connect-AzRetirementMonitor -UsingAPI again."
+ }
- $allRecommendations = [System.Collections.Generic.List[object]]::new()
+ $headers = @{
+ Authorization = "Bearer $script:AccessToken"
+ "Content-Type" = "application/json"
+ }
+ }
+ else {
+ # PowerShell module mode (default) - requires Az.Advisor and active session
+ if (-not (Test-AzAdvisorSession)) {
+ throw "Az.Advisor module not available or not connected. Run Connect-AzAccount first or use -UseAPI with Connect-AzRetirementMonitor."
+ }
+ }
}
process {
- if (-not $SubscriptionId) {
- $subsUri = "https://management.azure.com/subscriptions?api-version=2020-01-01"
- $subs = Invoke-AzPagedRequest -Uri $subsUri -Headers $headers
- $SubscriptionId = $subs.subscriptionId
- }
+ if ($UseAPI) {
+ # API-based retrieval (original implementation)
+ if (-not $SubscriptionId) {
+ $subsUri = "https://management.azure.com/subscriptions?api-version=2020-01-01"
+ $subs = Invoke-AzPagedRequest -Uri $subsUri -Headers $headers
+ $SubscriptionId = $subs.subscriptionId
+ }
+
+ foreach ($subId in $SubscriptionId) {
+ Write-Verbose "Querying subscription via API: $subId"
- foreach ($subId in $SubscriptionId) {
- Write-Verbose "Querying subscription: $subId"
+ $uri = "https://management.azure.com/subscriptions/$subId/providers/Microsoft.Advisor/recommendations?api-version=$script:ApiVersion"
- $uri = "https://management.azure.com/subscriptions/$subId/providers/Microsoft.Advisor/recommendations?api-version=$script:ApiVersion"
+ # Filter for HighAvailability category and ServiceUpgradeAndRetirement subcategory only
+ $filter = "Category eq 'HighAvailability' and SubCategory eq 'ServiceUpgradeAndRetirement'"
+ $uri += "&`$filter=$filter"
- # Filter for HighAvailability category and ServiceUpgradeAndRetirement subcategory only
- $filter = "Category eq 'HighAvailability' and SubCategory eq 'ServiceUpgradeAndRetirement'"
- $uri += "&`$filter=$filter"
+ try {
+ $recommendations = Invoke-AzPagedRequest `
+ -Uri $uri `
+ -Headers $headers
+ foreach ($rec in $recommendations) {
+ $isRetirement =
+ $rec.properties.shortDescription.problem -match
+ 'retire|deprecat|end of life|eol|sunset'
+
+ # Extract ResourceType from ResourceId
+ $resourceId = $rec.properties.resourceMetadata.resourceId
+ $resourceType = if ($resourceId) {
+ # Extract provider/type from resourceId
+ # Example: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/virtualMachines/{name}
+ if ($resourceId -match '/providers/([^/]+/[^/]+)(?:/|$)') {
+ $matches[1]
+ } else {
+ "N/A"
+ }
+ } else {
+ "N/A"
+ }
+
+ # Extract Resource Group from ResourceId
+ $resourceGroup = if ($resourceId) {
+ # Extract resource group name from resourceId
+ # Example: /subscriptions/{sub}/resourceGroups/{rg}/providers/...
+ if ($resourceId -match '/resourceGroups/([^/]+)') {
+ $matches[1]
+ } else {
+ "N/A"
+ }
+ } else {
+ "N/A"
+ }
+
+ # Build Azure Resource portal link
+ $resourceLink = if ($resourceId) {
+ "https://portal.azure.com/#resource$resourceId"
+ } else {
+ $null
+ }
+
+ $allRecommendations.Add([PSCustomObject]@{
+ SubscriptionId = $subId
+ ResourceId = $resourceId
+ ResourceName = ($resourceId -split "/")[-1]
+ ResourceType = $resourceType
+ ResourceGroup = $resourceGroup
+ Category = $rec.properties.category
+ Impact = $rec.properties.impact
+ Problem = $rec.properties.shortDescription.problem
+ Solution = $rec.properties.shortDescription.solution
+ Description = $rec.properties.extendedProperties.displayName
+ LastUpdated = $rec.properties.lastUpdated
+ IsRetirement = $isRetirement
+ RecommendationId = $rec.name
+ LearnMoreLink = $rec.properties.learnMoreLink
+ ResourceLink = $resourceLink
+ })
+ }
+ }
+ catch {
+ Write-Warning "Failed to query subscription $($subId): $_"
+ }
+ }
+ }
+ else {
+ # PowerShell module-based retrieval (new default)
try {
- $recommendations = Invoke-AzPagedRequest `
- -Uri $uri `
- -Headers $headers
+ # Get recommendations and filter by Category first (more efficient)
+ $filter = "Category eq 'HighAvailability'"
+
+ # Common filter for ServiceUpgradeAndRetirement subcategory
+ $subcategoryFilter = {
+ # Parse extended properties to check subcategory
+ if ($_.ExtendedProperty) {
+ $extProps = $_.ExtendedProperty | ConvertFrom-Json
+ $extProps.recommendationSubCategory -eq 'ServiceUpgradeAndRetirement'
+ }
+ else {
+ $false
+ }
+ }
+
+ $recommendations = if ($SubscriptionId) {
+ # Query specific subscriptions
+ # Store the current context to restore later
+ $originalContext = Get-AzContext
+
+ foreach ($subId in $SubscriptionId) {
+ Write-Verbose "Querying subscription via Az.Advisor: $subId"
+
+ # Set context to the specific subscription
+ try {
+ $context = Set-AzContext -SubscriptionId $subId -ErrorAction Stop
+
+ # Verify that the context was actually set to the intended subscription
+ if (-not $context -or -not $context.Subscription -or $context.Subscription.Id -ne $subId) {
+ Write-Warning "Azure context for subscription $($subId) could not be verified. Skipping recommendation query for this subscription."
+ continue
+ }
+
+ }
+ catch {
+ Write-Warning "Failed to set Azure context for subscription $($subId): $_"
+ continue
+ }
+
+ # Query Advisor recommendations for the current subscription
+ try {
+ Get-AzAdvisorRecommendation -Filter $filter | Where-Object $subcategoryFilter
+ }
+ catch {
+ Write-Warning "Failed to query Advisor recommendations for subscription $($subId): $_"
+ }
+ }
+
+ # Restore the original context
+ if ($originalContext) {
+ try {
+ $null = Set-AzContext -Context $originalContext -ErrorAction Stop
+ }
+ catch {
+ Write-Warning "Failed to restore original Azure context: $_"
+ }
+ }
+ }
+ else {
+ # Query all subscriptions
+ Write-Verbose "Querying all subscriptions via Az.Advisor"
+ Get-AzAdvisorRecommendation -Filter $filter | Where-Object $subcategoryFilter
+ }
foreach ($rec in $recommendations) {
- $isRetirement =
- $rec.properties.shortDescription.problem -match
- 'retire|deprecat|end of life|eol|sunset'
+ # Parse extended properties for retirement information
+ $extProps = $null
+ $retirementFeatureName = $null
+ $retirementDate = $null
+
+ if ($rec.ExtendedProperty) {
+ # Reuse a previously-parsed ExtendedProperty if available to avoid redundant JSON parsing
+ if ($rec.PSObject.Properties.Name -contains 'ExtendedPropertyObject') {
+ $extProps = $rec.ExtendedPropertyObject
+ }
+ else {
+ try {
+ if ($rec.ExtendedProperty -is [string]) {
+ # ExtendedProperty is JSON text; parse it once
+ $extProps = $rec.ExtendedProperty | ConvertFrom-Json
+ }
+ elseif ($rec.ExtendedProperty -is [hashtable] -or $rec.ExtendedProperty -is [pscustomobject]) {
+ # ExtendedProperty is already an object; no need to parse
+ $extProps = $rec.ExtendedProperty
+ }
+
+ if ($extProps) {
+ # Cache the parsed object on the recommendation to prevent re-parsing
+ $rec | Add-Member -NotePropertyName ExtendedPropertyObject -NotePropertyValue $extProps -Force
+ }
+ }
+ catch {
+ Write-Verbose "Failed to parse ExtendedProperty: $_"
+ $extProps = $null
+ }
+ }
+
+ if ($extProps) {
+ $retirementFeatureName = $extProps.retirementFeatureName
+ $retirementDate = $extProps.retirementDate
+ }
+ }
- # Extract ResourceType from ResourceId
- $resourceId = $rec.properties.resourceMetadata.resourceId
+ # Check if this is a retirement recommendation
+ # Look in both the text and the extended properties
+ $isRetirement = $false
+ if ($rec.ShortDescriptionProblem -match 'retire|deprecat|end of life|eol|sunset|migration') {
+ $isRetirement = $true
+ }
+ elseif ($retirementFeatureName -or $retirementDate) {
+ $isRetirement = $true
+ }
+
+ # Extract ResourceId from ResourceMetadataResourceId property
+ $resourceId = $rec.ResourceMetadataResourceId
+
$resourceType = if ($resourceId) {
- # Extract provider/type from resourceId
- # Example: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/virtualMachines/{name}
if ($resourceId -match '/providers/([^/]+/[^/]+)(?:/|$)') {
$matches[1]
} else {
@@ -66,10 +278,7 @@ Gets Azure service retirement recommendations for HighAvailability category and
"N/A"
}
- # Extract Resource Group from ResourceId
$resourceGroup = if ($resourceId) {
- # Extract resource group name from resourceId
- # Example: /subscriptions/{sub}/resourceGroups/{rg}/providers/...
if ($resourceId -match '/resourceGroups/([^/]+)') {
$matches[1]
} else {
@@ -79,6 +288,16 @@ Gets Azure service retirement recommendations for HighAvailability category and
"N/A"
}
+ $subscriptionId = if ($resourceId) {
+ if ($resourceId -match '/subscriptions/([^/]+)') {
+ $matches[1]
+ } else {
+ "N/A"
+ }
+ } else {
+ "N/A"
+ }
+
# Build Azure Resource portal link
$resourceLink = if ($resourceId) {
"https://portal.azure.com/#resource$resourceId"
@@ -86,27 +305,44 @@ Gets Azure service retirement recommendations for HighAvailability category and
$null
}
+ # Build description from extended properties
+ # Prefer retirementFeatureName, fall back to displayName
+ $description = if ($retirementFeatureName) {
+ if ($retirementDate) {
+ "$retirementFeatureName (Retirement Date: $retirementDate)"
+ }
+ else {
+ $retirementFeatureName
+ }
+ }
+ elseif ($extProps -and $extProps.displayName) {
+ $extProps.displayName
+ }
+ else {
+ $null
+ }
+
$allRecommendations.Add([PSCustomObject]@{
- SubscriptionId = $subId
+ SubscriptionId = $subscriptionId
ResourceId = $resourceId
- ResourceName = ($resourceId -split "/")[-1]
+ ResourceName = if ($resourceId) { ($resourceId -split "/")[-1] } else { "N/A" }
ResourceType = $resourceType
ResourceGroup = $resourceGroup
- Category = $rec.properties.category
- Impact = $rec.properties.impact
- Problem = $rec.properties.shortDescription.problem
- Solution = $rec.properties.shortDescription.solution
- Description = $rec.properties.extendedProperties.displayName
- LastUpdated = $rec.properties.lastUpdated
+ Category = $rec.Category
+ Impact = $rec.Impact
+ Problem = $rec.ShortDescriptionProblem
+ Solution = $rec.ShortDescriptionSolution
+ Description = $description
+ LastUpdated = $rec.LastUpdated
IsRetirement = $isRetirement
- RecommendationId = $rec.name
- LearnMoreLink = $rec.properties.learnMoreLink
+ RecommendationId = $rec.Name
+ LearnMoreLink = if ($rec.LearnMoreLink) { $rec.LearnMoreLink } else { $null }
ResourceLink = $resourceLink
})
}
}
catch {
- Write-Warning "Failed to query subscription $($subId) $_"
+ Write-Error "Failed to retrieve recommendations via Az.Advisor: $_"
}
}
}
diff --git a/QUICKSTART.md b/QUICKSTART.md
new file mode 100644
index 0000000..61231ba
--- /dev/null
+++ b/QUICKSTART.md
@@ -0,0 +1,137 @@
+# AzRetirementMonitor v2.0 - Quick Start Guide
+
+**Compatible with PowerShell 5.1+ (Desktop and Core)**
+
+## Quick Start (Recommended Method)
+
+```powershell
+# Step 1: Install required modules
+Install-Module -Name Az.Advisor, Az.Accounts -Scope CurrentUser
+
+# Step 2: Connect to Azure
+Connect-AzAccount
+
+# Step 3: Get retirement recommendations
+Get-AzRetirementRecommendation
+
+# Step 4: Export to HTML report
+Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.html" -Format HTML
+```
+
+## Alternative Method (REST API)
+
+```powershell
+# Step 1: Log in to Azure CLI
+az login
+
+# Step 2: Connect module for API access
+Connect-AzRetirementMonitor -UsingAPI
+
+# Step 3: Get retirement recommendations via API
+Get-AzRetirementRecommendation -UseAPI
+
+# Step 4: Export to CSV report
+Get-AzRetirementRecommendation -UseAPI | Export-AzRetirementReport -OutputPath "report.csv" -Format CSV
+
+# Step 5: Disconnect when done
+Disconnect-AzRetirementMonitor
+```
+
+## What Changed in v2.0?
+
+### ✅ Default Method (NEW)
+
+- Uses **Az.Advisor PowerShell module**
+- Full parity with Azure Portal
+- No need for `Connect-AzRetirementMonitor`
+- Just run `Connect-AzAccount` and go!
+
+### ⚙️ API Method (Still Available)
+
+- Uses **REST API** directly
+- Requires `Connect-AzRetirementMonitor -UsingAPI`
+- Use `-UseAPI` switch on `Get-AzRetirementRecommendation`
+
+## Common Commands
+
+### Get All Recommendations
+
+```powershell
+# Default method
+Get-AzRetirementRecommendation
+
+# API method
+Get-AzRetirementRecommendation -UseAPI
+```
+
+### Get Recommendations for Specific Subscriptions
+
+```powershell
+Get-AzRetirementRecommendation -SubscriptionId "12345678-1234-1234-1234-123456789012"
+```
+
+### Export to Different Formats
+
+```powershell
+# CSV
+Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.csv" -Format CSV
+
+# JSON
+Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.json" -Format JSON
+
+# HTML
+Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.html" -Format HTML
+```
+
+## Troubleshooting
+
+### "Az.Advisor module not available or not connected"
+
+**Solution**: Install the module and connect to Azure
+
+```powershell
+Install-Module -Name Az.Advisor -Scope CurrentUser
+Connect-AzAccount
+```
+
+### "Not authenticated. Run Connect-AzRetirementMonitor -UsingAPI first"
+
+**Solution**: You're trying to use API mode. Either:
+
+1. Remove `-UseAPI` to use default method, OR
+2. Run `Connect-AzRetirementMonitor -UsingAPI` first
+
+### "Connect-AzRetirementMonitor requires -UsingAPI parameter"
+
+**Solution**: This is expected! For default method, you don't need `Connect-AzRetirementMonitor`.
+
+```powershell
+# For default method (recommended)
+Connect-AzAccount # NOT Connect-AzRetirementMonitor
+
+# For API method only
+Connect-AzRetirementMonitor -UsingAPI
+```
+
+## Migration from v1.x
+
+**Old workflow:**
+
+```powershell
+Connect-AzRetirementMonitor
+Get-AzRetirementRecommendation
+```
+
+**New workflow (recommended):**
+
+```powershell
+Connect-AzAccount
+Get-AzRetirementRecommendation
+```
+
+**New workflow (API method):**
+
+```powershell
+Connect-AzRetirementMonitor -UsingAPI
+Get-AzRetirementRecommendation -UseAPI
+```
diff --git a/README.md b/README.md
index 8228171..2b06a64 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,36 @@ 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 2.0.0 - Breaking Changes
+
+**Version 2.0.0 introduces 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
+- **Connect-AzRetirementMonitor**: Now only needed for API mode and requires `-UsingAPI` switch
+- **PowerShell compatibility**: Now supports both PowerShell Desktop 5.1 and Core 7+
+
+### Migration Guide from v1.x
+
+**Old workflow (v1.x):**
+
+```powershell
+Connect-AzRetirementMonitor
+Get-AzRetirementRecommendation
+```
+
+**New workflow (v2.0):**
+
+```powershell
+# Default method (recommended - uses Az.Advisor module)
+Connect-AzAccount
+Get-AzRetirementRecommendation
+
+# API method (if you prefer the REST API)
+Connect-AzRetirementMonitor -UsingAPI
+Get-AzRetirementRecommendation -UseAPI
+```
+
## How Do I Install It?
### From PowerShell Gallery (Recommended)
@@ -37,16 +67,48 @@ Install-Module -Name AzRetirementMonitor -Scope CurrentUser
### Prerequisites
-- **PowerShell 7.0 or later**
-- **Authentication method** (one of the following):
- - Azure CLI (`az`) - Default and recommended
+**For Default Method (Recommended):**
+- **PowerShell 5.1 or later** (Compatible with both Desktop 5.1 and Core 7+)
+- Az.Advisor module: `Install-Module -Name Az.Advisor`
+- Az.Accounts module: `Install-Module -Name Az.Accounts`
+
+**For API Method (Alternative):**
+- **PowerShell 5.1 or later** (Compatible with both Desktop 5.1 and Core 7+)
+- One of the following:
+ - Azure CLI (`az`)
- Az.Accounts PowerShell module
## How Do I Authenticate?
-AzRetirementMonitor supports two authentication methods:
+### Default Method: Az.Advisor PowerShell Module (Recommended)
+
+This method provides **full parity** with Azure Advisor data.
+
+1. Install the required modules:
+
+ ```powershell
+ Install-Module -Name Az.Advisor, Az.Accounts -Scope CurrentUser
+ ```
+
+2. Connect to Azure:
+
+ ```powershell
+ Connect-AzAccount
+ ```
+
+3. Get retirement recommendations:
+
+ ```powershell
+ Get-AzRetirementRecommendation
+ ```
+
+**That's it!** No need to run `Connect-AzRetirementMonitor` for this method.
-### Option 1: Azure CLI (Default)
+### Alternative: REST API Method
+
+If you prefer to use the REST API instead of the Az.Advisor module:
+
+#### Option 1: Azure CLI
1. Install the [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli)
2. Log in to Azure:
@@ -55,13 +117,19 @@ AzRetirementMonitor supports two authentication methods:
az login
```
-3. Connect the module:
+3. Connect the module for API access:
+
+ ```powershell
+ Connect-AzRetirementMonitor -UsingAPI
+ ```
+
+4. Get retirement recommendations:
```powershell
- Connect-AzRetirementMonitor
+ Get-AzRetirementRecommendation -UseAPI
```
-### Option 2: Az PowerShell Module
+#### Option 2: Az PowerShell Module
1. Install Az.Accounts:
@@ -75,31 +143,63 @@ AzRetirementMonitor supports two authentication methods:
Connect-AzAccount
```
-3. Connect the module using Az PowerShell:
+3. Connect the module for API access using Az PowerShell:
+
+ ```powershell
+ Connect-AzRetirementMonitor -UsingAPI -UseAzPowerShell
+ ```
+
+4. Get retirement recommendations:
```powershell
- Connect-AzRetirementMonitor -UseAzPowerShell
+ Get-AzRetirementRecommendation -UseAPI
```
## What Commands Are Available?
+### Get-AzRetirementRecommendation
+
+**Main command** - Retrieves Azure Advisor recommendations related to service retirements and deprecations.
+
+**Default behavior (v2.0+)**: Uses Az.Advisor PowerShell module
+**Alternative**: Use `-UseAPI` switch to query REST API directly
+
+```powershell
+# Get all retirement recommendations (default Az.Advisor method)
+Get-AzRetirementRecommendation
+
+# Get recommendations for specific subscriptions
+Get-AzRetirementRecommendation -SubscriptionId "sub-id-1", "sub-id-2"
+
+# Use REST API instead (requires Connect-AzRetirementMonitor -UsingAPI first)
+Get-AzRetirementRecommendation -UseAPI
+```
+
+**Parameters:**
+
+- `SubscriptionId` - One or more subscription IDs (defaults to all subscriptions)
+- `UseAPI` - Use REST API instead of Az.Advisor module
+
### Connect-AzRetirementMonitor
-Authenticates to Azure and obtains an access token for subsequent API calls.
+⚠️ **IMPORTANT**: This command is **only needed for API mode** (when using `-UseAPI`).
+
+For the default Az.Advisor method, use `Connect-AzAccount` instead.
-The token is validated to ensure it's scoped to `https://management.azure.com` and is used exclusively for read-only Azure Advisor operations.
+Authenticates to Azure and obtains an access token for REST API calls.
**Parameters:**
+- `-UsingAPI` (required): Confirms you intend to use API-based access
- `-UseAzCLI` (default): Use Azure CLI authentication
- `-UseAzPowerShell`: Use Az.Accounts PowerShell module authentication
```powershell
-# Using Azure CLI (default)
-Connect-AzRetirementMonitor
+# For API access with Azure CLI (default)
+Connect-AzRetirementMonitor -UsingAPI
-# Using Az PowerShell module
-Connect-AzRetirementMonitor -UseAzPowerShell
+# For API access with Az PowerShell module
+Connect-AzRetirementMonitor -UsingAPI -UseAzPowerShell
```
### Disconnect-AzRetirementMonitor
@@ -108,34 +208,20 @@ Clears the access token stored by the module. This does not affect your Azure CL
The token is securely cleared from module memory and cannot be recovered after disconnection.
-```powershell
-# Disconnect from AzRetirementMonitor
-Disconnect-AzRetirementMonitor
-```
-
-### Get-AzRetirementRecommendation
-
-Retrieves Azure Advisor recommendations related to service retirements and deprecations. This function specifically returns only HighAvailability category recommendations with ServiceUpgradeAndRetirement subcategory.
+**Only relevant when using API mode.**
```powershell
-# Get all retirement recommendations across all subscriptions
-Get-AzRetirementRecommendation
-
-# Get recommendations for specific subscriptions
-Get-AzRetirementRecommendation -SubscriptionId "sub-id-1", "sub-id-2"
+Disconnect-AzRetirementMonitor
```
-**Parameters:**
-
-- `SubscriptionId` - One or more subscription IDs (defaults to all subscriptions)
-
-**Note:** This function is hardcoded to return only recommendations where Category is 'HighAvailability' and SubCategory is 'ServiceUpgradeAndRetirement'.
-
### Get-AzRetirementMetadataItem
-Retrieves metadata about retirement recommendation types from Azure Advisor, filtered for HighAvailability category and ServiceUpgradeAndRetirement subcategory.
+Retrieves metadata about retirement recommendation types from Azure Advisor.
+
+**Note**: This function only works with API mode as Az.Advisor module does not expose metadata cmdlets.
```powershell
+# Requires Connect-AzRetirementMonitor -UsingAPI first
Get-AzRetirementMetadataItem
```
@@ -143,6 +229,8 @@ Get-AzRetirementMetadataItem
Exports retirement recommendations to various formats for reporting and analysis.
+Works with recommendations from both default (Az.Advisor) and API methods.
+
```powershell
# Export to CSV
Get-AzRetirementRecommendation | Export-AzRetirementReport -OutputPath "report.csv" -Format CSV
@@ -176,17 +264,17 @@ For more information, see [Azure Advisor documentation](https://learn.microsoft.
## Example Output
-### Get-AzRetirementRecommendation
+### Get-AzRetirementRecommendation (Default Method)
```powershell
-PS> Connect-AzRetirementMonitor
-Authenticated to Azure successfully
-
+PS> Connect-AzAccount
PS> Get-AzRetirementRecommendation
SubscriptionId : 12345678-1234-1234-1234-123456789012
ResourceId : /subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myRG/providers/Microsoft.Compute/virtualMachines/myVM
ResourceName : myVM
+ResourceType : Microsoft.Compute/virtualMachines
+ResourceGroup : myRG
Category : HighAvailability
Impact : High
Problem : Virtual machine is using a retiring VM size
@@ -196,6 +284,7 @@ LastUpdated : 2024-01-15T10:30:00Z
IsRetirement : True
RecommendationId : abc123-def456-ghi789
LearnMoreLink : https://learn.microsoft.com/azure/virtual-machines/sizes-previous-gen
+ResourceLink : https://portal.azure.com/#resource/subscriptions/.../myVM
```
### Export-AzRetirementReport
@@ -212,33 +301,67 @@ This creates an HTML report with all retirement recommendations, including resou
## Usage Workflow
-Here's a typical workflow for monitoring Azure retirements:
+### Recommended Workflow (Default Az.Advisor Method)
```powershell
-# 1. Authenticate
-Connect-AzRetirementMonitor
+# 1. Ensure Az.Advisor is installed
+Install-Module -Name Az.Advisor, Az.Accounts -Scope CurrentUser
+
+# 2. Authenticate to Azure
+Connect-AzAccount
-# 2. Get retirement recommendations
+# 3. Get retirement recommendations
$recommendations = Get-AzRetirementRecommendation
+# 4. Review the recommendations
+$recommendations | Format-Table ResourceName, Impact, Problem, Solution -AutoSize
+
+# 5. Export for team review
+$recommendations | Export-AzRetirementReport -OutputPath "retirement-report.html" -Format HTML
+```
+
+### Alternative Workflow (API Method)
+
+```powershell
+# 1. Authenticate for API access
+Connect-AzRetirementMonitor -UsingAPI
+
+# 2. Get retirement recommendations via API
+$recommendations = Get-AzRetirementRecommendation -UseAPI
+
# 3. Review the recommendations
-$recommendations | Format-Table ResourceName, Impact, Problem, Solution
+$recommendations | Format-Table ResourceName, Impact, Problem, Solution -AutoSize
# 4. Export for team review
$recommendations | Export-AzRetirementReport -OutputPath "retirement-report.csv" -Format CSV
-# 5. Get metadata about retirement types (optional)
+# 5. Get metadata about retirement types (API only)
Get-AzRetirementMetadataItem
-# 6. Disconnect when finished (optional)
+# 6. Disconnect when finished
Disconnect-AzRetirementMonitor
```
+## Comparison: Default vs API Method
+
+| Feature | Default (Az.Advisor) | API Method |
+|---------|---------------------|------------|
+| **Data Parity** | ✅ Full parity with Azure Portal | ⚠️ May have slight differences |
+| **Authentication** | `Connect-AzAccount` | `Connect-AzRetirementMonitor -UsingAPI` |
+| **Module Required** | Az.Advisor, Az.Accounts | None (uses REST API) |
+| **Usage** | `Get-AzRetirementRecommendation` | `Get-AzRetirementRecommendation -UseAPI` |
+| **Metadata** | ❌ Not available | ✅ `Get-AzRetirementMetadataItem` |
+| **Recommended** | ✅ Yes | Use when Az.Advisor unavailable |
+
## How Does Authentication and Token Management Work?
-### Security Model
+### Default Method (Az.Advisor)
+
+Uses standard Azure PowerShell authentication via `Connect-AzAccount`. The Az.Advisor module handles all authentication and token management internally. Your credentials are managed by the Az.Accounts module using industry-standard OAuth flows.
-AzRetirementMonitor uses a **read-only, scoped token** approach to ensure security and isolation:
+### API Method
+
+The API method uses a **read-only, scoped token** approach to ensure security and isolation:
1. **Token Acquisition**: The module obtains a time-limited access token from your existing Azure authentication:
- **Azure CLI**: Uses `az account get-access-token` to request a token from your logged-in session
@@ -257,23 +380,23 @@ AzRetirementMonitor uses a **read-only, scoped token** approach to ensure securi
- Cannot be used to modify Azure resources
4. **Module Isolation**: The module's authentication is completely isolated:
- - `Connect-AzRetirementMonitor` **does not** authenticate you to Azure (you must already be logged in)
- - `Connect-AzRetirementMonitor` **only** requests an access token from your existing session
+ - `Connect-AzRetirementMonitor -UsingAPI` **does not** authenticate you to Azure (you must already be logged in)
+ - `Connect-AzRetirementMonitor -UsingAPI` **only** requests an access token from your existing session
- `Disconnect-AzRetirementMonitor` **only** clears the module's stored token
- `Disconnect-AzRetirementMonitor` **does not** affect your Azure CLI or Az.Accounts session
- You remain logged in to Azure CLI (`az login`) or Az.Accounts (`Connect-AzAccount`) after disconnecting
-### Token Lifecycle
+### Token Lifecycle (API Method)
```powershell
# You authenticate to Azure first (outside the module)
az login # or Connect-AzAccount
# Module requests a token from your session (does not re-authenticate)
-Connect-AzRetirementMonitor
+Connect-AzRetirementMonitor -UsingAPI
# Module uses the token for API calls
-Get-AzRetirementRecommendation
+Get-AzRetirementRecommendation -UseAPI
# Module clears its token (you remain logged in to Azure)
Disconnect-AzRetirementMonitor
diff --git a/Tests/AzRetirementMonitor.Tests.ps1 b/Tests/AzRetirementMonitor.Tests.ps1
index b4a5f83..cb49862 100644
--- a/Tests/AzRetirementMonitor.Tests.ps1
+++ b/Tests/AzRetirementMonitor.Tests.ps1
@@ -108,7 +108,7 @@ Describe "Connect-AzRetirementMonitor SecureString Handling" {
}
# Call Connect-AzRetirementMonitor with UseAzPowerShell
- Connect-AzRetirementMonitor -UseAzPowerShell
+ Connect-AzRetirementMonitor -UsingAPI -UseAzPowerShell
# Verify the token was set correctly in module scope
$module = Get-Module AzRetirementMonitor
@@ -131,7 +131,7 @@ Describe "Connect-AzRetirementMonitor SecureString Handling" {
}
# Call Connect-AzRetirementMonitor with UseAzPowerShell
- Connect-AzRetirementMonitor -UseAzPowerShell
+ Connect-AzRetirementMonitor -UsingAPI -UseAzPowerShell
# Verify the token was set correctly in module scope
$module = Get-Module AzRetirementMonitor
@@ -201,6 +201,62 @@ Describe "Get-AzRetirementRecommendation" {
}
}
+Describe "Get-AzRetirementRecommendation Context Switching Logic" {
+ It "Should have context management code in the function" {
+ # Verify that the function source contains the necessary context management logic
+ $functionDef = (Get-Command Get-AzRetirementRecommendation).Definition
+
+ # Check for Get-AzContext call to save original context
+ $functionDef | Should -Match 'Get-AzContext'
+
+ # Check for Set-AzContext with SubscriptionId parameter
+ $functionDef | Should -Match 'Set-AzContext\s+-SubscriptionId'
+
+ # Check for context verification logic
+ $functionDef | Should -Match 'could not be verified'
+
+ # Check for context restoration with Context parameter
+ $functionDef | Should -Match 'Set-AzContext\s+-Context'
+ }
+
+ It "Should have error handling for Set-AzContext failures" {
+ $functionDef = (Get-Command Get-AzRetirementRecommendation).Definition
+
+ # Check for try-catch around Set-AzContext
+ $functionDef | Should -Match 'try\s*\{[^}]*Set-AzContext'
+ $functionDef | Should -Match 'Failed to set Azure context for subscription'
+ }
+
+ It "Should have error handling for context restoration" {
+ $functionDef = (Get-Command Get-AzRetirementRecommendation).Definition
+
+ # Check for error handling around context restoration
+ $functionDef | Should -Match 'Failed to restore original Azure context'
+ }
+
+ It "Should have error handling for Get-AzAdvisorRecommendation failures" {
+ $functionDef = (Get-Command Get-AzRetirementRecommendation).Definition
+
+ # Check for error handling around Get-AzAdvisorRecommendation
+ $functionDef | Should -Match 'Failed to query Advisor recommendations'
+ }
+
+ It "Should verify subscription context after setting it" {
+ $functionDef = (Get-Command Get-AzRetirementRecommendation).Definition
+
+ # Check that the function verifies the context was set correctly
+ $functionDef | Should -Match 'context\.Subscription\.Id'
+ $functionDef | Should -Match '\$subId'
+ }
+
+ It "Should use continue statement to skip failed subscriptions" {
+ $functionDef = (Get-Command Get-AzRetirementRecommendation).Definition
+
+ # Check for continue statements in error handling
+ $functionDef | Should -Match 'continue'
+ }
+}
+
Describe "Get-AzRetirementMetadataItem" {
It "Should have no parameters" {
$cmd = Get-Command Get-AzRetirementMetadataItem
@@ -236,6 +292,162 @@ Describe "Export-AzRetirementReport" {
}
}
+Describe "Export-AzRetirementReport Transformation Logic" {
+ BeforeAll {
+ # Create a temporary directory for test outputs
+ $script:TestOutputDir = Join-Path ([System.IO.Path]::GetTempPath()) "AzRetirementMonitorTests_$([guid]::NewGuid())"
+ New-Item -Path $script:TestOutputDir -ItemType Directory -Force | Out-Null
+ }
+
+ AfterAll {
+ # Clean up test output directory
+ if (Test-Path $script:TestOutputDir) {
+ Remove-Item -Path $script:TestOutputDir -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ }
+
+ Context "When Problem equals Solution (Az.Advisor mode)" {
+ It "Should replace Solution with Description in CSV when Description exists" {
+ $testRec = [PSCustomObject]@{
+ ResourceName = "TestVM"
+ ResourceType = "Microsoft.Compute/virtualMachines"
+ Problem = "Generic retirement notice"
+ Solution = "Generic retirement notice"
+ Description = "Detailed upgrade instructions for this VM"
+ ResourceGroup = "test-rg"
+ SubscriptionId = "test-sub-id"
+ Impact = "High"
+ }
+
+ $outputPath = Join-Path $script:TestOutputDir "test-advisor-mode.csv"
+ $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format CSV -Confirm:$false
+
+ $result = Import-Csv -Path $outputPath
+ $result.Solution | Should -Be "Detailed upgrade instructions for this VM"
+ }
+
+ It "Should replace Solution with Description in JSON when Description exists" {
+ $testRec = [PSCustomObject]@{
+ ResourceName = "TestStorage"
+ ResourceType = "Microsoft.Storage/storageAccounts"
+ Problem = "Service retiring"
+ Solution = "Service retiring"
+ Description = "Migrate to new storage account type"
+ ResourceGroup = "test-rg"
+ SubscriptionId = "test-sub-id"
+ Impact = "Medium"
+ }
+
+ $outputPath = Join-Path $script:TestOutputDir "test-advisor-mode.json"
+ $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format JSON -Confirm:$false
+
+ $result = Get-Content -Path $outputPath -Raw | ConvertFrom-Json
+ $result.Solution | Should -Be "Migrate to new storage account type"
+ }
+
+ It "Should replace Solution with Description in HTML when Description exists" {
+ $testRec = [PSCustomObject]@{
+ ResourceName = "TestDB"
+ ResourceType = "Microsoft.Sql/servers/databases"
+ Problem = "Upgrade required"
+ Solution = "Upgrade required"
+ Description = "Move to SQL Database v2"
+ ResourceGroup = "test-rg"
+ SubscriptionId = "test-sub-id"
+ Impact = "Low"
+ ResourceLink = "https://portal.azure.com/resource"
+ LearnMoreLink = "https://learn.microsoft.com/azure"
+ }
+
+ $outputPath = Join-Path $script:TestOutputDir "test-advisor-mode.html"
+ $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format HTML -Confirm:$false
+
+ $htmlContent = Get-Content -Path $outputPath -Raw
+ $htmlContent | Should -Match "Move to SQL Database v2"
+ }
+ }
+
+ Context "When Problem differs from Solution (API mode)" {
+ It "Should keep original Solution in CSV when Problem differs" {
+ $testRec = [PSCustomObject]@{
+ ResourceName = "TestApp"
+ ResourceType = "Microsoft.Web/sites"
+ Problem = "API version deprecated"
+ Solution = "Update to API version 2023-01-01"
+ Description = "Additional context"
+ ResourceGroup = "test-rg"
+ SubscriptionId = "test-sub-id"
+ Impact = "High"
+ }
+
+ $outputPath = Join-Path $script:TestOutputDir "test-api-mode.csv"
+ $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format CSV -Confirm:$false
+
+ $result = Import-Csv -Path $outputPath
+ $result.Solution | Should -Be "Update to API version 2023-01-01"
+ }
+
+ It "Should keep original Solution in JSON when Problem differs" {
+ $testRec = [PSCustomObject]@{
+ ResourceName = "TestFunction"
+ ResourceType = "Microsoft.Web/sites/functions"
+ Problem = "Runtime version retiring"
+ Solution = "Upgrade to .NET 8"
+ Description = "Migration guide available"
+ ResourceGroup = "test-rg"
+ SubscriptionId = "test-sub-id"
+ Impact = "Medium"
+ }
+
+ $outputPath = Join-Path $script:TestOutputDir "test-api-mode.json"
+ $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format JSON -Confirm:$false
+
+ $result = Get-Content -Path $outputPath -Raw | ConvertFrom-Json
+ $result.Solution | Should -Be "Upgrade to .NET 8"
+ }
+ }
+
+ Context "Edge cases" {
+ It "Should keep Solution when Description is null even if Problem equals Solution" {
+ $testRec = [PSCustomObject]@{
+ ResourceName = "TestResource"
+ ResourceType = "Microsoft.Test/resources"
+ Problem = "Action required"
+ Solution = "Action required"
+ Description = $null
+ ResourceGroup = "test-rg"
+ SubscriptionId = "test-sub-id"
+ Impact = "Low"
+ }
+
+ $outputPath = Join-Path $script:TestOutputDir "test-null-description.csv"
+ $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format CSV -Confirm:$false
+
+ $result = Import-Csv -Path $outputPath
+ $result.Solution | Should -Be "Action required"
+ }
+
+ It "Should keep Solution when Description is empty string even if Problem equals Solution" {
+ $testRec = [PSCustomObject]@{
+ ResourceName = "TestResource2"
+ ResourceType = "Microsoft.Test/resources"
+ Problem = "Update needed"
+ Solution = "Update needed"
+ Description = ""
+ ResourceGroup = "test-rg"
+ SubscriptionId = "test-sub-id"
+ Impact = "Low"
+ }
+
+ $outputPath = Join-Path $script:TestOutputDir "test-empty-description.csv"
+ $testRec | Export-AzRetirementReport -OutputPath $outputPath -Format CSV -Confirm:$false
+
+ $result = Import-Csv -Path $outputPath
+ $result.Solution | Should -Be "Update needed"
+ }
+ }
+}
+
Describe "Token Expiration Validation" {
BeforeAll {
# Helper function to create a test JWT token with specified expiration
@@ -275,7 +487,7 @@ Describe "Token Expiration Validation" {
}
It "Get-AzRetirementRecommendation should throw when not authenticated" {
- { Get-AzRetirementRecommendation -ErrorAction Stop } | Should -Throw "*Not authenticated*"
+ { Get-AzRetirementRecommendation -UseAPI -ErrorAction Stop } | Should -Throw "*Not authenticated*"
}
It "Get-AzRetirementMetadataItem should throw when token is expired" {
@@ -299,7 +511,7 @@ Describe "Token Expiration Validation" {
$module = Get-Module AzRetirementMonitor
& $module { param($token) $script:AccessToken = $token } $expiredToken
- { Get-AzRetirementRecommendation -ErrorAction Stop } | Should -Throw "*expired*"
+ { Get-AzRetirementRecommendation -UseAPI -ErrorAction Stop } | Should -Throw "*expired*"
}
It "Should validate a token with future expiration as valid" {