Skip to content

Instantly share code, notes, and snippets.

@kfrancis
Forked from davidfowl/cleanrg.cs
Last active June 17, 2025 23:37
Show Gist options
  • Save kfrancis/cd1d559ffe99f207f2cff6147e529070 to your computer and use it in GitHub Desktop.
Save kfrancis/cd1d559ffe99f207f2cff6147e529070 to your computer and use it in GitHub Desktop.
A C# tool for cleaning up resource groups
// -----------------------------------------------------------------------------
// 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 [email protected]
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 = 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
var arm = new ArmClient(new DefaultAzureCredential());
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 arm.GetSubscriptions())
subList.Add(s);
});
if (subList.Count == 0)
{
AnsiConsole.MarkupLine("[red]❌ No subscriptions found.[/]");
return;
}
var subChoices = subList.Select(s => $"{s.Data.DisplayName} ({s.Data.SubscriptionId})").ToList();
subChoices.Add("Cancel");
var selectedSub = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Select an Azure subscription:")
.AddChoices(subChoices)
.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]πŸ” Analyzing subscription [yellow]{sub.Data.DisplayName}[/]…[/]");
// Helper function to analyze a resource group
async Task<RgInfo> AnalyzeResourceGroup(ResourceGroupResource 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
);
}
// 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 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;
}
// 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 by user.[/]");
return;
}
List<RgInfo> toDelete = [];
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;
}
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)
);
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)");
}
var finalConfirm = AnsiConsole.Confirm(
$"\n[bold]Are you absolutely sure you want to delete these {toDelete.Count} resource groups?[/]",
defaultValue: false
);
if (!finalConfirm)
{
AnsiConsole.MarkupLine("[red]❌ Aborted.[/]");
return;
}
// 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(false)
.Columns(
[
new TaskDescriptionColumn(),
new ProgressBarColumn(),
new SpinnerColumn(),
new ElapsedTimeColumn(),
])
.StartAsync(async ctx =>
{
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 = toDelete.Select(async rgInfo =>
{
await gate.WaitAsync();
try
{
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
{
gate.Release();
overallTask.Increment(1);
}
});
await Task.WhenAll(deleteTasks);
});
// 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)
// ═══════════════════════════════════════════════════════════════════════════════
// 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
);
@kfrancis
Copy link
Author

Added the following, over @DavidFowler's version:

🧠 Intelligent Analysis

  • Resource inventory: Examines what's actually inside each resource group
  • Pattern matching: Identifies test/demo/poc/temp/sandbox naming patterns
  • Critical service detection: Flags running VMs, databases, and production services
  • Age analysis: Considers resource groups that haven't been touched in 30+ days
  • Tag inspection: Looks for environment tags (prod/production vs dev/test)

πŸ›‘οΈ Safety Features

  • Risk classification: LOW/MEDIUM/HIGH risk levels (only shows LOW/MEDIUM for cleanup)
  • Dry run mode: Preview exactly what would be deleted without doing it
  • Detailed preview: Shows resource types and counts before any deletion
  • Multiple confirmation steps: Can't accidentally delete everything
  • Better error handling: Won't crash if it hits access issues

🎯 Smart Targeting
The script identifies cleanup candidates by looking for:

  • Resource groups with test-related naming (test, demo, poc, temp, etc.)
  • Nearly empty resource groups (≀3 resources)
  • No running virtual machines or databases
  • No production indicators in names or tags
  • Older resource groups (30+ days since last modification)

πŸ“Š Enhanced Visibility

  • Detailed table: Shows risk level, resource count, age, and patterns for each candidate
  • Resource breakdown: Lists what types of resources are in each group
  • Summary statistics: Clear counts of low/medium/high risk items
  • Progress tracking: Real-time status during analysis and deletion

πŸ”§ Flexible Options

  • Dry run: See what would be deleted (recommended first step)
  • Auto-delete low risk: Only delete obvious test/empty resource groups
  • Manual selection: Pick specific resource groups from the candidates
  • Smart exclusions: Automatically hides anything that looks production-related

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment