-
-
Save kfrancis/cd1d559ffe99f207f2cff6147e529070 to your computer and use it in GitHub Desktop.
A C# tool for cleaning up resource groups
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ----------------------------------------------------------------------------- | |
// 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 | |
); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Added the following, over @DavidFowler's version:
π§ Intelligent Analysis
π‘οΈ Safety Features
π― Smart Targeting
The script identifies cleanup candidates by looking for:
π Enhanced Visibility
π§ Flexible Options