Created
June 16, 2025 21:42
-
-
Save davidfowl/5e049dcbdeaa485fbafdbc0b9feeaab7 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 Janitor - Spectre Edition | |
// Run with: dotnet run cleanup-rg.cs | |
// Targets: .NET 10 Preview 4+ | |
// ----------------------------------------------------------------------------- | |
#:package Azure.Identity@1.* | |
#:package Azure.ResourceManager@1.* | |
#:package Spectre.Console@0.50.0 | |
using Azure.Identity; | |
using Azure.ResourceManager; | |
using Azure.ResourceManager.Resources; | |
using Spectre.Console; | |
// ─────────────── settings ───────────────────────────────────────────────────── | |
const int MaxParallel = 10; // limit concurrent deletions | |
// ────────────────────────────────────────────────────────────────────────────── | |
// 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) | |
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) | |
.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}[/]…[/]"); | |
// 2️⃣ Find candidate resource groups | |
var matches = new List<ResourceGroupResource>(); | |
await foreach (var rg in sub.GetResourceGroups().GetAllAsync()) | |
{ | |
matches.Add(rg); | |
} | |
if (matches.Count == 0) | |
{ | |
AnsiConsole.MarkupLine("[green]✔ No resource groups match.[/]"); | |
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) | |
{ | |
AnsiConsole.MarkupLine("[red]❌ Aborted – nothing selected.[/]"); | |
return; | |
} | |
matches = matches.Where(rg => selectedRgs.Contains(rg.Data.Name)).OrderBy(rg => rg.Data.Name, StringComparer.OrdinalIgnoreCase).ToList(); | |
// 1️⃣ pretty-print the list | |
var table = new Table() | |
.Border(TableBorder.Rounded) | |
.AddColumn("#") | |
.AddColumn("Name"); | |
int idx = 1; | |
foreach (var rg in matches) | |
table.AddRow(idx++.ToString(), rg.Data.Name); | |
AnsiConsole.Write(table); | |
// 2️⃣ confirmation | |
bool proceed = AnsiConsole.Confirm( | |
$"Delete [yellow]{matches.Count}[/] resource group(s) shown above?", defaultValue: false); | |
if (!proceed) | |
{ | |
AnsiConsole.MarkupLine("[red]❌ Aborted – nothing deleted.[/]"); | |
return; | |
} | |
// 3️⃣ delete with a progress bar | |
AnsiConsole.MarkupLine("[bold]🗑 Deleting…[/]"); | |
using var gate = new SemaphoreSlim(MaxParallel); | |
await AnsiConsole.Progress() | |
.AutoClear(true) | |
.Columns( | |
[ | |
new TaskDescriptionColumn(), | |
new ProgressBarColumn(), | |
new SpinnerColumn(), | |
new ElapsedTimeColumn(), | |
]) | |
.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 deleteTasks = matches.Select(async rg => | |
{ | |
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; | |
} | |
finally | |
{ | |
gate.Release(); | |
overallTask.Increment(1); | |
} | |
}); | |
await Task.WhenAll(deleteTasks); | |
}); | |
AnsiConsole.MarkupLine("[bold green]✅ Cleanup completed.[/]"); | |
// Print what was deleted | |
AnsiConsole.MarkupLine("[bold]Deleted resource groups:[/]"); | |
foreach (var rg in matches) | |
AnsiConsole.MarkupLine($" [yellow]{rg.Data.Name}[/]"); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
There's no filtering because you literally have to select the rg to delete 😄