Skip to content

Instantly share code, notes, and snippets.

@davidfowl
Created June 16, 2025 21:42
Show Gist options
  • Save davidfowl/5e049dcbdeaa485fbafdbc0b9feeaab7 to your computer and use it in GitHub Desktop.
Save davidfowl/5e049dcbdeaa485fbafdbc0b9feeaab7 to your computer and use it in GitHub Desktop.
A C# tool for cleaning up resource groups
// -----------------------------------------------------------------------------
// 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 [email protected]
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}[/]");
@kfrancis
Copy link

kfrancis commented Jun 17, 2025

Hmm, this does work, but it seems kind of dangerous. There's no filtering, there's no "dry run". What happens when I select an RG that is in use and shouldn't be deleted?

Added a fork with some improvements

@davidfowl
Copy link
Author

There's no filtering because you literally have to select the rg to delete 😄

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