// ----------------------------------------------------------------------------- // 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 Spectre.Console@0.50.0 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(); 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() .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 AnalyzeResourceGroup(ResourceGroupResource rg) { var resources = new List(); var resourceTypes = new List(); var hasRunningVMs = false; var hasDatabases = false; var hasProductionIndicators = false; DateTime? lastModified = null; try { // Get all resources in the RG - Fixed: removed GetAll() as Pageable 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(); 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(); var analyzed = 0; await AnsiConsole.Progress() .AutoClear(false) .Columns( [ new TaskDescriptionColumn(), new ProgressBarColumn(), new SpinnerColumn(), new ElapsedTimeColumn(), ]) .StartAsync(async ctx => { var rgList = new List(); // Fixed: removed GetAll() as Pageable 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(); 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() .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 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() .Title("Select resource groups to delete:") .NotRequired() .PageSize(15) .InstructionsText("[grey](Press [blue][/] to toggle, [green][/] 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 ResourceTypes );