@@ -1,32 +1,41 @@
// -----------------------------------------------------------------------------
// Azure RG Janitor - Spectre Edition
// Run with: dotnet run cleanup-rg .cs
// Azure RG Smart Janitor - Enhanced Cleanup Tool
// Run with: dotnet run smart-cleanup .cs
// Targets: .NET 10 Preview 4+
// -----------------------------------------------------------------------------
#: package Azure . Identity@1 . *
#: package Azure . ResourceManager @1 . *
#: package Azure . ResourceManager . Compute @1 . *
#: package Azure . ResourceManager . Storage @1 . *
#: package Azure . ResourceManager . Sql @1 . *
#: package Spectre . Console @0.50 .0
using Azure . Identity ;
using Azure . ResourceManager ;
using Azure . ResourceManager . Resources ;
using Azure . ResourceManager . Compute ;
using Azure . ResourceManager . Storage ;
using Azure . ResourceManager . Sql ;
using Spectre . Console ;
using System . Text . RegularExpressions ;
// ─────────────── settings ─────────────────────────────────────────────────────
const int MaxParallel = 10 ; // limit concurrent deletions
// ─────────────── Settings ─────────────────────────────────────────────────────
const int MaxParallel = 5 ; // Reduced for safety
const int StaleAgeDays = 30 ; // Consider RGs older than this as potentially stale
const int EmptyResourceThreshold = 3 ; // RGs with <= this many resources are candidates
// ──────────────────────────────────────────────────────────────────────────────
// 1️⃣ Select subscription
// 1️⃣ Select subscription
var arm = new ArmClient ( new DefaultAzureCredential ( ) ) ;
var subs = arm . GetSubscriptions ( ) . GetAllAsync ( ) ;
var subList = new List < SubscriptionResource > ( ) ;
await AnsiConsole . Status ( )
. Spinner ( Spinner . Known . Dots )
. SpinnerStyle ( Style . Parse ( "yellow" ) )
. StartAsync ( "Fetching Azure subscriptions..." , async ctx =>
{
await foreach ( var s in subs )
await foreach ( var s in arm . GetSubscriptions ( ) )
subList . Add ( s ) ;
} ) ;
@@ -42,88 +51,342 @@ await AnsiConsole.Status()
new SelectionPrompt < string > ( )
. Title ( "Select an Azure subscription:" )
. AddChoices ( subChoices )
. UseConverter ( x => x ) // keep display as is
. PageSize ( 10 )
. MoreChoicesText ( "[grey](Move up and down to reveal more subscriptions)[/]" )
. HighlightStyle ( "blue" )
. EnableSearch ( )
) ;
if ( selectedSub == "Cancel" )
{
AnsiConsole . MarkupLine ( "[red]❌ Aborted by user.[/]" ) ;
return ;
}
var subIdx = subChoices . IndexOf ( selectedSub ) ;
var sub = subList [ subIdx ] ;
AnsiConsole . MarkupLine ( $ "[bold blue]🔍 Scanning subscription [yellow]{ sub . Data . DisplayName } [/]…[/]") ;
AnsiConsole . MarkupLine ( $ "[bold blue]🔍 Analyzing subscription [yellow]{ sub . Data . DisplayName } [/]…[/]") ;
// 2️⃣ Find candidate resource groups
var matches = new List < ResourceGroupResource > ( ) ;
await foreach ( var rg in sub . GetResourceGroups ( ) . GetAllAsync ( ) )
// Helper function to analyze a resource group
async Task < RgInfo > AnalyzeResourceGroup ( ResourceGroupResource rg )
{
matches . Add ( rg ) ;
var resources = new List < string > ( ) ;
var resourceTypes = new List < string > ( ) ;
var hasRunningVMs = false ;
var hasDatabases = false ;
var hasProductionIndicators = false ;
DateTime ? lastModified = null ;
try
{
// Get all resources in the RG - Fixed: removed GetAll() as Pageable<T> is already IEnumerable
var genericResources = rg . GetGenericResources ( ) ;
foreach ( var resource in genericResources )
{
resources . Add ( resource . Data . Name ) ;
var resourceType = resource . Data . ResourceType . Type ;
if ( ! resourceTypes . Contains ( resourceType ) )
resourceTypes . Add ( resourceType ) ;
// Check for running VMs
if ( resourceType == "virtualMachines" )
{
try
{
var vmResource = await rg . GetVirtualMachines ( ) . GetAsync ( resource . Data . Name ) ;
if ( vmResource . Value . Data . InstanceView ? . Statuses ? . Any ( s =>
s . Code ? . Contains ( "PowerState/running" ) == true ) == true )
{
hasRunningVMs = true ;
}
}
catch { /* Ignore access errors */ }
}
// Check for databases
if ( resourceType == "servers" || resourceType == "databases" ||
resourceType == "sqlServers" || resourceType == "managedInstances" )
{
hasDatabases = true ;
}
// Update last modified
if ( resource . Data . CreatedOn . HasValue &&
( ! lastModified . HasValue || resource . Data . CreatedOn . Value . DateTime > lastModified ) )
{
lastModified = resource . Data . CreatedOn . Value . DateTime ;
}
}
}
catch
{
// If we can't enumerate resources, be conservative
hasProductionIndicators = true ;
}
// Check for production indicators
var rgName = rg . Data . Name . ToLowerInvariant ( ) ;
var tags = rg . Data . Tags ?? new Dictionary < string , string > ( ) ;
hasProductionIndicators = hasProductionIndicators ||
rgName . Contains ( "prod" ) ||
rgName . Contains ( "production" ) ||
tags . Any ( t => t . Key . ToLowerInvariant ( ) . Contains ( "env" ) &&
t . Value . ToLowerInvariant ( ) . Contains ( "prod" ) ) ||
tags . Any ( t => t . Value . ToLowerInvariant ( ) . Contains ( "production" ) ) ;
// Check if it matches test patterns
var testPatterns = new [ ] { "test" , "demo" , "poc" , "temp" , "sandbox" , "dev" , "experiment" , "trial" , "sample" } ;
var matchesTestPattern = testPatterns . Any ( pattern => rgName . Contains ( pattern ) ) ||
tags . Any ( t => testPatterns . Any ( p => t . Value . ToLowerInvariant ( ) . Contains ( p ) ) ) ;
// Determine risk level
var riskLevel = "HIGH" ; // Default to high risk
if ( ! hasRunningVMs && ! hasDatabases && ! hasProductionIndicators )
{
if ( matchesTestPattern || resources . Count <= EmptyResourceThreshold )
riskLevel = "LOW" ;
else if ( lastModified . HasValue && ( DateTime . UtcNow - lastModified . Value ) . Days > StaleAgeDays )
riskLevel = "MEDIUM" ;
else
riskLevel = "MEDIUM" ;
}
return new RgInfo (
rg ,
resources . Count ,
hasRunningVMs ,
hasDatabases ,
hasProductionIndicators ,
matchesTestPattern ,
lastModified ,
riskLevel ,
resourceTypes
) ;
}
if ( matches . Count == 0 )
// 2️⃣ Analyze all resource groups intelligently
var allRgs = new List < RgInfo > ( ) ;
var analyzed = 0 ;
await AnsiConsole . Progress ( )
. AutoClear ( false )
. Columns (
[
new TaskDescriptionColumn ( ) ,
new ProgressBarColumn ( ) ,
new SpinnerColumn ( ) ,
new ElapsedTimeColumn ( ) ,
] )
. StartAsync ( async ctx =>
{
var rgList = new List < ResourceGroupResource > ( ) ;
// Fixed: removed GetAll() as Pageable<T> is already IEnumerable
foreach ( var rg in sub . GetResourceGroups ( ) )
rgList . Add ( rg ) ;
var analysisTask = ctx . AddTask ( $ "[blue]Analyzing resource groups[/]", maxValue : rgList . Count ) ;
foreach ( var rg in rgList )
{
try
{
analysisTask . Description = $ "[blue]Analyzing[/] [yellow]{ rg . Data . Name } [/]";
var rgInfo = await AnalyzeResourceGroup ( rg ) ;
allRgs . Add ( rgInfo ) ;
analyzed ++ ;
}
catch ( Exception ex )
{
AnsiConsole . MarkupLine ( $ "[red]⚠ Error analyzing { rg . Data . Name } : { ex . Message } [/]") ;
}
analysisTask . Increment ( 1 ) ;
}
} ) ;
AnsiConsole . MarkupLine ( $ "[green]✅ Analyzed { analyzed } resource groups[/]") ;
// 3️⃣ Filter for cleanup candidates
var cleanupCandidates = allRgs . Where ( rg =>
rg . RiskLevel == "LOW" || rg . RiskLevel == "MEDIUM"
) . OrderBy ( rg => rg . RiskLevel )
. ThenBy ( rg => rg . ResourceGroup . Data . Name , StringComparer . OrdinalIgnoreCase )
. ToList ( ) ;
if ( cleanupCandidates . Count == 0 )
{
AnsiConsole . MarkupLine ( "[green]✔ No resource groups match.[/]" ) ;
AnsiConsole . MarkupLine ( "[green]✔ No cleanup candidates found. Your subscription looks clean![/]" ) ;
// Show summary of what was excluded
var excluded = allRgs . Where ( rg => rg . RiskLevel == "HIGH" ) . ToList ( ) ;
if ( excluded . Count > 0 )
{
AnsiConsole . MarkupLine ( $ "\n [yellow]ℹ { excluded . Count } resource groups were excluded due to production indicators:[/]") ;
foreach ( var rg in excluded . Take ( 5 ) )
AnsiConsole . MarkupLine ( $ " • [yellow]{ rg . ResourceGroup . Data . Name } [/] - { rg . ResourceTypes . Count } resources") ;
if ( excluded . Count > 5 )
AnsiConsole . MarkupLine ( $ " • [grey]... and { excluded . Count - 5 } more[/]") ;
}
return ;
}
// 3️⃣ Pick list for resource groups
var rgChoices = matches . Select ( rg => $ "{ rg . Data . Name } ") . OrderBy ( name => name , StringComparer . OrdinalIgnoreCase ) . ToList ( ) ;
matches = matches . OrderBy ( rg => rg . Data . Name , StringComparer . OrdinalIgnoreCase ) . ToList ( ) ;
var prompt = new MultiSelectionPrompt < string > ( )
. Title ( $ "Select resource groups to delete ([yellow]{ matches . Count } [/] found):")
. NotRequired ( )
. PageSize ( 10 )
. InstructionsText ( "[grey](Press [blue]<space>[/] to toggle, [green]<enter>[/] to accept, type to search)[/]" )
. AddChoices ( rgChoices ) ;
// foreach (var choice in rgChoices)
// prompt.Select(choice); // pre-select all
var selectedRgs = AnsiConsole . Prompt ( prompt ) ?? rgChoices ;
AnsiConsole . MarkupLine ( $ "[grey]Selected resource groups:[/]") ;
foreach ( var name in selectedRgs )
AnsiConsole . MarkupLine ( $ " [yellow]{ name } [/]") ;
if ( selectedRgs . Count == 0 )
// 4️⃣ Display cleanup candidates in a detailed table
var table = new Table ( )
. Border ( TableBorder . Rounded )
. AddColumn ( "Risk" )
. AddColumn ( "Name" )
. AddColumn ( "Resources" )
. AddColumn ( "Age" )
. AddColumn ( "Patterns" )
. AddColumn ( "Details" ) ;
foreach ( var rg in cleanupCandidates )
{
var riskColor = rg . RiskLevel switch
{
"LOW" => "green" ,
"MEDIUM" => "yellow" ,
_ => "red"
} ;
var ageText = rg . LastModified ? . ToString ( "yyyy-MM-dd" ) ?? "Unknown" ;
var ageDays = rg . LastModified . HasValue ? ( DateTime . UtcNow - rg . LastModified . Value ) . Days : 0 ;
if ( ageDays > StaleAgeDays )
ageText += $ " ([red]{ ageDays } d[/])";
var patterns = new List < string > ( ) ;
if ( rg . MatchesTestPattern ) patterns . Add ( "Test name" ) ;
if ( rg . ResourceCount <= EmptyResourceThreshold ) patterns . Add ( "Few resources" ) ;
if ( ! rg . HasRunningVMs && ! rg . HasDatabases ) patterns . Add ( "No critical services" ) ;
var details = string . Join ( ", " , rg . ResourceTypes . Take ( 3 ) ) ;
if ( rg . ResourceTypes . Count > 3 )
details += $ ", +{ rg . ResourceTypes . Count - 3 } more";
table . AddRow (
$ "[{ riskColor } ]{ rg . RiskLevel } [/]",
rg . ResourceGroup . Data . Name ,
rg . ResourceCount . ToString ( ) ,
ageText ,
string . Join ( ", " , patterns ) ,
details
) ;
}
AnsiConsole . Write ( table ) ;
// 5️⃣ Show totals and options
AnsiConsole . MarkupLine ( $ "\n [bold]Summary:[/]") ;
AnsiConsole . MarkupLine ( $ " • [green]{ cleanupCandidates . Count ( c => c . RiskLevel == "LOW" ) } [/] low-risk candidates") ;
AnsiConsole . MarkupLine ( $ " • [yellow]{ cleanupCandidates . Count ( c => c . RiskLevel == "MEDIUM" ) } [/] medium-risk candidates") ;
AnsiConsole . MarkupLine ( $ " • [red]{ allRgs . Count ( rg => rg . RiskLevel == "HIGH" ) } [/] high-risk (excluded)") ;
// 6️⃣ Dry run option
var mode = AnsiConsole . Prompt (
new SelectionPrompt < string > ( )
. Title ( "What would you like to do?" )
. AddChoices ( [
"Dry run - Show what would be deleted" ,
"Select specific resource groups to delete" ,
"Auto-delete only LOW risk items" ,
"Cancel"
] )
. HighlightStyle ( "blue" )
) ;
if ( mode == "Cancel" )
{
AnsiConsole . MarkupLine ( "[red]❌ Aborted – nothing selected .[/]" ) ;
AnsiConsole . MarkupLine ( "[red]❌ Aborted by user .[/]" ) ;
return ;
}
matches = matches . Where ( rg => selectedRgs . Contains ( rg . Data . Name ) ) . OrderBy ( rg => rg . Data . Name , StringComparer . OrdinalIgnoreCase ) . ToList ( ) ;
List < RgInfo > toDelete = [ ] ;
// 1️⃣ pretty-print the list
var table = new Table ( )
. Border ( TableBorder . Rounded )
. AddColumn ( "#" )
. AddColumn ( "Name" ) ;
if ( mode == "Dry run - Show what would be deleted" )
{
toDelete = cleanupCandidates . Where ( c => c . RiskLevel == "LOW" ) . ToList ( ) ;
AnsiConsole . MarkupLine ( $ "\n [bold blue]🔍 DRY RUN - Would delete { toDelete . Count } low-risk resource groups:[/]") ;
foreach ( var rg in toDelete )
{
AnsiConsole . MarkupLine ( $ " • [yellow]{ rg . ResourceGroup . Data . Name } [/]") ;
AnsiConsole . MarkupLine ( $ " - { rg . ResourceCount } resources: { string . Join ( ", " , rg . ResourceTypes ) } ") ;
}
AnsiConsole . MarkupLine ( "\n [grey]No actual deletions performed.[/]" ) ;
return ;
}
else if ( mode == "Auto-delete only LOW risk items" )
{
toDelete = cleanupCandidates . Where ( c => c . RiskLevel == "LOW" ) . ToList ( ) ;
}
else if ( mode == "Select specific resource groups to delete" )
{
var rgChoices = cleanupCandidates . Select ( rg =>
$ "{ rg . ResourceGroup . Data . Name } ({ rg . RiskLevel } risk - { rg . ResourceCount } resources)"
) . ToList ( ) ;
if ( rgChoices . Count == 0 )
{
AnsiConsole . MarkupLine ( "[yellow]No candidates available for selection.[/]" ) ;
return ;
}
int idx = 1 ;
foreach ( var rg in matches )
table . AddRow ( idx ++ . ToString ( ) , rg . Data . Name ) ;
var selectedRgNames = AnsiConsole . Prompt (
new MultiSelectionPrompt < string > ( )
. Title ( "Select resource groups to delete:" )
. NotRequired ( )
. PageSize ( 15 )
. InstructionsText ( "[grey](Press [blue]<space>[/] to toggle, [green]<enter>[/] to accept)[/]" )
. AddChoices ( rgChoices )
) ;
AnsiConsole . Write ( table ) ;
if ( selectedRgNames . Count == 0 )
{
AnsiConsole . MarkupLine ( "[red]❌ Nothing selected.[/]" ) ;
return ;
}
toDelete = cleanupCandidates . Where ( rg =>
selectedRgNames . Any ( s => s . StartsWith ( rg . ResourceGroup . Data . Name + " (" ) )
) . ToList ( ) ;
}
if ( toDelete . Count == 0 )
{
AnsiConsole . MarkupLine ( "[yellow]No resource groups to delete.[/]" ) ;
return ;
}
// 7️⃣ Final confirmation with detailed breakdown
AnsiConsole . MarkupLine ( $ "\n [bold red]⚠ DELETION CONFIRMATION[/]") ;
AnsiConsole . MarkupLine ( $ "About to delete [yellow]{ toDelete . Count } [/] resource groups:") ;
foreach ( var rg in toDelete )
{
AnsiConsole . MarkupLine ( $ " • [yellow]{ rg . ResourceGroup . Data . Name } [/] ([{ ( rg . RiskLevel == "LOW" ? "green" : "yellow" ) } ]{ rg . RiskLevel } [/] risk)") ;
}
// 2️⃣ confirmation
bool proceed = AnsiConsole . Confirm (
$ "Delete [yellow]{ matches . Count } [/] resource group(s) shown above?", defaultValue : false ) ;
var finalConfirm = AnsiConsole . Confirm (
$ "\n [bold]Are you absolutely sure you want to delete these { toDelete . Count } resource groups?[/]",
defaultValue : false
) ;
if ( ! proceed )
if ( ! finalConfirm )
{
AnsiConsole . MarkupLine ( "[red]❌ Aborted – nothing deleted .[/]" ) ;
AnsiConsole . MarkupLine ( "[red]❌ Aborted.[/]" ) ;
return ;
}
// 3️⃣ delete with a progress bar
AnsiConsole . MarkupLine ( "[bold]🗑 Deleting… [/]" ) ;
// 8️⃣ Execute deletions with enhanced progress tracking
AnsiConsole . MarkupLine ( "[bold]🗑 Deleting resource groups... [/]" ) ;
using var gate = new SemaphoreSlim ( MaxParallel ) ;
var successCount = 0 ;
var failureCount = 0 ;
await AnsiConsole . Progress ( )
. AutoClear ( true )
. AutoClear ( false )
. Columns (
[
new TaskDescriptionColumn ( ) ,
@@ -133,20 +396,31 @@ await AnsiConsole.Progress()
] )
. StartAsync ( async ctx =>
{
var overallTask = ctx . AddTask ( "Overall progress" , maxValue : matches . Count ) ;
var rgTasks = matches . ToDictionary (
rg => rg . Data . Name ,
rg => ctx . AddTask ( $ "[grey]Deleting:[/] [yellow]{ rg . Data . Name } [/]", maxValue : 1 )
var overallTask = ctx . AddTask ( "Overall progress" , maxValue : toDelete . Count ) ;
var rgTasks = toDelete . ToDictionary (
rg => rg . ResourceGroup . Data . Name ,
rg => ctx . AddTask ( $ "[grey]Deleting:[/] [yellow]{ rg . ResourceGroup . Data . Name } [/]", maxValue : 1 )
) ;
var deleteTasks = matches . Select ( async rg =>
var deleteTasks = toDelete . Select ( async rgInfo =>
{
await gate . WaitAsync ( ) ;
try
{
rgTasks [ rg . Data . Name ] . Increment ( 0.5 ) ; // show started
await rg . DeleteAsync ( Azure . WaitUntil . Completed ) ;
rgTasks [ rg . Data . Name ] . Value = 1 ;
var rgTask = rgTasks [ rgInfo . ResourceGroup . Data . Name ] ;
rgTask . Increment ( 0.3 ) ;
await rgInfo . ResourceGroup . DeleteAsync ( Azure . WaitUntil . Completed ) ;
rgTask . Value = 1 ;
rgTask . Description = $ "[green]✅ Deleted:[/] [yellow]{ rgInfo . ResourceGroup . Data . Name } [/]";
Interlocked . Increment ( ref successCount ) ;
}
catch ( Exception ex )
{
var rgTask = rgTasks [ rgInfo . ResourceGroup . Data . Name ] ;
rgTask . Description = $ "[red]❌ Failed:[/] [yellow]{ rgInfo . ResourceGroup . Data . Name } [/] - { ex . Message } ";
Interlocked . Increment ( ref failureCount ) ;
}
finally
{
@@ -158,9 +432,27 @@ await AnsiConsole.Progress()
await Task . WhenAll ( deleteTasks ) ;
} ) ;
AnsiConsole . MarkupLine ( "[bold green]✅ Cleanup completed.[/]" ) ;
// 9️⃣ Final results
AnsiConsole . MarkupLine ( $ "\n [bold]Cleanup Results:[/]") ;
AnsiConsole . MarkupLine ( $ " • [green]✅ Successfully deleted: { successCount } [/]") ;
if ( failureCount > 0 )
AnsiConsole . MarkupLine ( $ " • [red]❌ Failed to delete: { failureCount } [/]") ;
AnsiConsole . MarkupLine ( "[bold green]🎉 Cleanup completed![/]" ) ;
// ═══════════════════════════════════════════════════════════════════════════════
// Type definitions (must be at the end with top-level statements)
// ═══════════════════════════════════════════════════════════════════════════════
// Print what was deleted
AnsiConsole . MarkupLine ( "[bold]Deleted resource groups:[/]" ) ;
foreach ( var rg in matches )
AnsiConsole . MarkupLine ( $ " [yellow]{ rg . Data . Name } [/]") ;
// Enhanced resource group info
public record RgInfo (
ResourceGroupResource ResourceGroup ,
int ResourceCount ,
bool HasRunningVMs ,
bool HasDatabases ,
bool HasProductionIndicators ,
bool MatchesTestPattern ,
DateTime ? LastModified ,
string RiskLevel ,
List < string > ResourceTypes
) ;