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.

Revisions

  1. davidfowl created this gist Jun 16, 2025.
    166 changes: 166 additions & 0 deletions cleanrg.cs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,166 @@
    // -----------------------------------------------------------------------------
    // 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}[/]");