diff --git a/Tingle.AzdoCleaner.Tests/AzdoEventHandlerTests.cs b/Tingle.AzdoCleaner.Tests/AzdoEventHandlerTests.cs index ad90fd8..846a8f6 100644 --- a/Tingle.AzdoCleaner.Tests/AzdoEventHandlerTests.cs +++ b/Tingle.AzdoCleaner.Tests/AzdoEventHandlerTests.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Caching.Memory; +using Azure.Core; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Text.Json; @@ -47,6 +48,41 @@ public void TryFindProject_Works() Assert.Equal("123456789", token); } + [Fact] + public void MakePossibleNames_Works() + { + Assert.Equal(new[] { "review-app-23765", "ra-23765", "ra23765", }, + AzdoEventHandler.MakePossibleNames(new[] { 23765, })); + Assert.Equal(new[] { "review-app-23765", "ra-23765", "ra23765", "review-app-50", "ra-50", "ra50", }, + AzdoEventHandler.MakePossibleNames(new[] { 23765, 50, })); + } + + [Fact] + public void NameMatchesExpectedFormat_Works() + { + var possibleNames = AzdoEventHandler.MakePossibleNames(new[] { 23765, }); + + // works for all in exact format + var modified = possibleNames; + Assert.All(modified, pn => AzdoEventHandler.NameMatchesExpectedFormat(possibleNames, pn)); + + // works when prefixed + modified = possibleNames.Select(pn => $"bla:{pn}").ToList(); + Assert.All(modified, pn => AzdoEventHandler.NameMatchesExpectedFormat(possibleNames, pn)); + + // works when suffixed + modified = possibleNames.Select(pn => $"{pn}:bla").ToList(); + Assert.All(modified, pn => AzdoEventHandler.NameMatchesExpectedFormat(possibleNames, pn)); + + // works for AppServicePlan + var planId = new ResourceIdentifier($"/subscriptions/{Guid.Empty}/resourceGroups/FABRIKAM/providers/Microsoft.Web/serverfarms/fabrikam-sites-ra23765"); + Assert.True(AzdoEventHandler.NameMatchesExpectedFormat(possibleNames, planId)); + + // works for ManagedEnvironment + var envId = new ResourceIdentifier($"/subscriptions/{Guid.Empty}/resourceGroups/FABRIKAM/providers/Microsoft.App/managedEnvironments/fabrikam-sites-ra-23765"); + Assert.True(AzdoEventHandler.NameMatchesExpectedFormat(possibleNames, envId)); + } + [Fact] public async Task HandleAsync_Works() { diff --git a/Tingle.AzdoCleaner/AzdoEventHandler.cs b/Tingle.AzdoCleaner/AzdoEventHandler.cs index 6513ef1..dd76f68 100644 --- a/Tingle.AzdoCleaner/AzdoEventHandler.cs +++ b/Tingle.AzdoCleaner/AzdoEventHandler.cs @@ -94,7 +94,7 @@ protected virtual async Task DeleteReviewAppResourcesAsync(AzdoProjectUrl url, s var credential = new DefaultAzureCredential(); var client = new ArmClient(credential); - var possibleNames = prIds.SelectMany(prId => new[] { $"review-app-{prId}", $"ra-{prId}", $"ra{prId}", }).ToHashSet().ToList(); + var possibleNames = MakePossibleNames(prIds); if (token is not null) { await DeleteReviewAppsEnvironmentsAsync(url, token, possibleNames, cancellationToken); @@ -143,24 +143,33 @@ protected virtual async Task DeleteReviewAppResourcesAsync(AzdoProjectUrl url, s } } } - protected virtual async Task DeleteAzureResourceGroupsAsync(SubscriptionResource sub, List possibleNames, CancellationToken cancellationToken) + protected virtual async Task DeleteAzureResourceGroupsAsync(SubscriptionResource sub, IReadOnlyCollection possibleNames, CancellationToken cancellationToken) { var groups = sub.GetResourceGroups(); await foreach (var group in groups) { var name = group.Data.Name; - if (possibleNames.Any(n => name.EndsWith(n) || name.StartsWith(n))) + if (NameMatchesExpectedFormat(possibleNames, name)) { logger.LogInformation("Deleting resource group '{ResourceGroupName}' at '{ResourceId}'", name, group.Data.Id); await group.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken: cancellationToken); } } } - protected virtual async Task DeleteAzureKubernetesNamespacesAsync(SubscriptionResource sub, IReadOnlyList possibleNames, CancellationToken cancellationToken) + protected virtual async Task DeleteAzureKubernetesNamespacesAsync(SubscriptionResource sub, IReadOnlyCollection possibleNames, CancellationToken cancellationToken) { var clusters = sub.GetContainerServiceManagedClustersAsync(cancellationToken); await foreach (var cluster in clusters) { + // delete matching clusters + var name = cluster.Data.Name; + if (NameMatchesExpectedFormat(possibleNames, name)) + { + logger.LogInformation("Deleting AKS cluster '{ClusterName}' at '{ResourceId}'", name, cluster.Data.Id); + await cluster.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken); + continue; // nothing more for the cluster + } + // skip stopped clusters if (cluster.Data.PowerStateCode == ContainerServiceStateCode.Stopped) continue; @@ -172,7 +181,7 @@ protected virtual async Task DeleteAzureKubernetesNamespacesAsync(SubscriptionRe possibleNames.Count, string.Join(",", possibleNames)); var namespaces = await kubeClient.ListNamespaceAsync(cancellationToken: cancellationToken); // using labelSelector causes problems, no idea why - var found = namespaces.Items.Where(ns => possibleNames.Any(n => ns.Metadata.Name.EndsWith(n) || ns.Metadata.Name.StartsWith(n))).ToList(); + var found = namespaces.Items.Where(ns => NameMatchesExpectedFormat(possibleNames, ns.Metadata.Name)).ToList(); if (found.Count > 0) { var names = found.Select(n => n.Metadata.Name).ToList(); @@ -189,29 +198,17 @@ protected virtual async Task DeleteAzureKubernetesNamespacesAsync(SubscriptionRe } } } - protected virtual async Task DeleteAzureWebsitesAsync(SubscriptionResource sub, List possibleNames, CancellationToken cancellationToken) + protected virtual async Task DeleteAzureWebsitesAsync(SubscriptionResource sub, IReadOnlyCollection possibleNames, CancellationToken cancellationToken) { var sites = sub.GetWebSitesAsync(cancellationToken); await foreach (var site in sites) { - // delete matching sites - var name = site.Data.Name; - if (possibleNames.Any(n => name.EndsWith(n) || name.StartsWith(n))) - { - logger.LogInformation("Deleting website '{WebsiteName}' in Plan '{ResourceId}'", name, site.Data.AppServicePlanId); - await site.DeleteAsync(Azure.WaitUntil.Completed, - deleteMetrics: true, - deleteEmptyServerFarm: false, - cancellationToken: cancellationToken); - continue; // nothing more for the site - } - // delete matching slots var slots = site.GetWebSiteSlots().GetAllAsync(cancellationToken); await foreach (var slot in slots) { var slotName = slot.Data.Name; - if (possibleNames.Contains(slotName, StringComparer.OrdinalIgnoreCase)) + if (NameMatchesExpectedFormat(possibleNames, slotName)) { logger.LogInformation("Deleting slot '{SlotName}' in Website '{ResourceId}'", slotName, site.Data.Id); await slot.DeleteAsync(Azure.WaitUntil.Completed, @@ -220,16 +217,29 @@ await slot.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken: cancellationToken); } } + + // delete matching sites (either the name or the plan indicates a reviewapp) + var name = site.Data.Name; + var planName = site.Data.AppServicePlanId.Name; + if (NameMatchesExpectedFormat(possibleNames, name) || NameMatchesExpectedFormat(possibleNames, planName)) + { + //site.Data.AppServicePlanId + logger.LogInformation("Deleting website '{WebsiteName}' in Plan '{ResourceId}'", name, site.Data.AppServicePlanId); + await site.DeleteAsync(Azure.WaitUntil.Completed, + deleteMetrics: true, + deleteEmptyServerFarm: false, + cancellationToken: cancellationToken); + } } } - protected virtual async Task DeleteAzureStaticWebAppsAsync(SubscriptionResource sub, List possibleNames, CancellationToken cancellationToken) + protected virtual async Task DeleteAzureStaticWebAppsAsync(SubscriptionResource sub, IReadOnlyCollection possibleNames, CancellationToken cancellationToken) { var sites = sub.GetStaticSitesAsync(cancellationToken); await foreach (var site in sites) { // delete matching sites var name = site.Data.Name; - if (possibleNames.Any(n => name.EndsWith(n) || name.StartsWith(n))) + if (NameMatchesExpectedFormat(possibleNames, name)) { logger.LogInformation("Deleting static site '{WebsiteName}'", name); await site.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken); @@ -247,7 +257,7 @@ protected virtual async Task DeleteAzureStaticWebAppsAsync(SubscriptionResource await foreach (var build in builds) { var buildName = build.Data.Name; - if (possibleNames.Contains(buildName, StringComparer.OrdinalIgnoreCase)) + if (NameMatchesExpectedFormat(possibleNames, buildName)) { logger.LogInformation("Deleting build '{BuildName}' in Static WebApp '{ResourceId}'", buildName, site.Data.Id); await build.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken); @@ -255,42 +265,68 @@ protected virtual async Task DeleteAzureStaticWebAppsAsync(SubscriptionResource } } } - protected virtual async Task DeleteAzureContainerAppsAsync(SubscriptionResource sub, List possibleNames, CancellationToken cancellationToken) + protected virtual async Task DeleteAzureContainerAppsAsync(SubscriptionResource sub, IReadOnlyCollection possibleNames, CancellationToken cancellationToken) { + // delete matching container apps (either the name or the environment indicates a reviewapp) var apps = sub.GetContainerAppsAsync(cancellationToken); await foreach (var app in apps) { var name = app.Data.Name; - if (possibleNames.Any(n => name.EndsWith(n) || name.StartsWith(n))) + var envName = app.Data.EnvironmentId.Name; + if (NameMatchesExpectedFormat(possibleNames, name) || NameMatchesExpectedFormat(possibleNames, envName)) { logger.LogInformation("Deleting app '{ContainerAppName}' in Environment '{ResourceId}'", name, app.Data.ManagedEnvironmentId); await app.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken); } } + + // delete matching environments + var envs = sub.GetContainerAppManagedEnvironmentsAsync(cancellationToken); + await foreach (var env in envs) + { + var name = env.Data.Name; + if (NameMatchesExpectedFormat(possibleNames, name)) + { + logger.LogInformation("Deleting environment '{EnvironmentName}' at '{ResourceId}'", name, env.Data.Id); + await env.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken); + } + } } - protected virtual async Task DeleteAzureContainerInstancesAsync(SubscriptionResource sub, List possibleNames, CancellationToken cancellationToken) + protected virtual async Task DeleteAzureContainerInstancesAsync(SubscriptionResource sub, IReadOnlyCollection possibleNames, CancellationToken cancellationToken) { var groups = sub.GetContainerGroupsAsync(cancellationToken); await foreach (var group in groups) { var name = group.Data.Name; - if (possibleNames.Any(n => name.EndsWith(n) || name.StartsWith(n))) + if (NameMatchesExpectedFormat(possibleNames, name)) { logger.LogInformation("Deleting app '{ContainerGroupName}' at '{ResourceId}'", name, group.Data.Id); await group.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken); } } } - protected virtual async Task DeleteAzureSqlAsync(SubscriptionResource sub, List possibleNames, CancellationToken cancellationToken) + protected virtual async Task DeleteAzureSqlAsync(SubscriptionResource sub, IReadOnlyCollection possibleNames, CancellationToken cancellationToken) { var servers = sub.GetSqlServersAsync(cancellationToken: cancellationToken); await foreach (var server in servers) { // delete matching servers var name = server.Data.Name; - if (possibleNames.Any(n => name.EndsWith(n) || name.StartsWith(n))) + if (NameMatchesExpectedFormat(possibleNames, name)) { - logger.LogInformation("Deleting SQL Server '{SqlServerName}'", name); + // delete databases in the server + logger.LogInformation("Deleting databases for SQL Server '{SqlServerName}' at '{ResourceId}'", name, server.Data.Id); + var serverDatabases = server.GetSqlDatabases().GetAllAsync(cancellationToken: cancellationToken); + await foreach (var database in serverDatabases) + { + var databaseName = database.Data.Name; + if (databaseName.Equals("master")) continue; + logger.LogInformation("Deleting database '{DatabaseName}' at '{ResourceId}'", databaseName, database.Data.Id); + await database.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken: cancellationToken); + } + + // delete the actual server + logger.LogInformation("Deleting SQL Server '{SqlServerName}' at '{ResourceId}'", name, server.Data.Id); await server.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken: cancellationToken); continue; // nothing more for the server } @@ -300,9 +336,21 @@ protected virtual async Task DeleteAzureSqlAsync(SubscriptionResource sub, List< await foreach (var pool in pools) { var poolName = pool.Data.Name; - if (possibleNames.Any(n => name.EndsWith(n) || name.StartsWith(n))) + if (NameMatchesExpectedFormat(possibleNames, poolName)) { - logger.LogInformation("Deleting elastic pool '{DatabaseName}' in Website '{ResourceId}'", poolName, pool.Data.Id); + // delete databases in the pool + logger.LogInformation("Deleting databases for elastic pool '{ElasticPoolName}' at '{ResourceId}'", poolName, pool.Data.Id); + var poolDatabases = pool.GetDatabasesAsync(cancellationToken); + await foreach (var database in poolDatabases) + { + var databaseName = database.Data.Name; + if (databaseName.Equals("master")) continue; + logger.LogInformation("Deleting database '{DatabaseName}' at '{ResourceId}'", databaseName, database.Data.Id); + await database.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken: cancellationToken); + } + + // delete the actual pool + logger.LogInformation("Deleting elastic pool '{ElasticPoolName}' at '{ResourceId}'", poolName, pool.Data.Id); await pool.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken: cancellationToken); } } @@ -312,19 +360,19 @@ protected virtual async Task DeleteAzureSqlAsync(SubscriptionResource sub, List< await foreach (var database in databases) { var databaseName = database.Data.Name; - if (possibleNames.Any(n => name.EndsWith(n) || name.StartsWith(n))) + if (databaseName.Equals("master")) continue; + if (NameMatchesExpectedFormat(possibleNames, databaseName)) { - logger.LogInformation("Deleting database '{DatabaseName}' in Website '{ResourceId}'", databaseName, database.Data.Id); + logger.LogInformation("Deleting database '{DatabaseName}' at '{ResourceId}'", databaseName, database.Data.Id); await database.DeleteAsync(Azure.WaitUntil.Completed, cancellationToken: cancellationToken); } } } } - protected virtual async Task DeleteReviewAppsEnvironmentsAsync(AzdoProjectUrl url, string token, IReadOnlyList names, CancellationToken cancellationToken) + protected virtual async Task DeleteReviewAppsEnvironmentsAsync(AzdoProjectUrl url, string token, IReadOnlyCollection possibleNames, CancellationToken cancellationToken) { var connection = CreateVssConnection(url, token); - var client = await connection.GetClientAsync(cancellationToken); // iterate through all environments and resources @@ -343,7 +391,7 @@ protected virtual async Task DeleteReviewAppsEnvironmentsAsync(AzdoProjectUrl ur foreach (var resource in environment.Resources) { - if (names.Contains(resource.Name, StringComparer.OrdinalIgnoreCase)) + if (NameMatchesExpectedFormat(possibleNames, resource.Name)) { logger.LogInformation("Deleting resource '{EnvironmentName}/{ResourceName}' in '{ProjectUrl}'", environment.Name, resource.Name, url); await client.DeleteKubernetesResourceAsync(url.ProjectIdOrName, environment.Id, resource.Id, cancellationToken: cancellationToken); @@ -360,12 +408,24 @@ protected virtual async Task GetAzureKubernetesCl var response = await cluster.GetClusterAdminCredentialsAsync(cancellationToken: cancellationToken); var credentials = response.Value; var kubeConfig = FindConfig(credentials, "admin") - ?? FindConfig(credentials, "clusterAdmin") - ?? throw new InvalidOperationException("Unable to get the cluster credentials"); + ?? FindConfig(credentials, "clusterAdmin") + ?? throw new InvalidOperationException("Unable to get the cluster credentials"); using var ms = new MemoryStream(kubeConfig.Value); return await KubernetesClientConfiguration.BuildConfigFromConfigFileAsync(ms); } + internal static IReadOnlyCollection MakePossibleNames(IEnumerable ids) + { + return ids.SelectMany(prId => new[] { $"review-app-{prId}", $"ra-{prId}", $"ra{prId}", }) + .ToHashSet(); + } + + internal static bool NameMatchesExpectedFormat(IReadOnlyCollection possibleNames, Azure.Core.ResourceIdentifier id) + => NameMatchesExpectedFormat(possibleNames, id.Name); + + internal static bool NameMatchesExpectedFormat(IReadOnlyCollection possibleNames, string name) + => possibleNames.Any(n => name.EndsWith(n) || name.StartsWith(n)); + protected virtual VssConnection CreateVssConnection(AzdoProjectUrl url, string token) { static string hash(string v)