-
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add CLI functionality to allow invoking via docker (#137)
- Loading branch information
1 parent
aa4360b
commit aec752c
Showing
7 changed files
with
206 additions
and
151 deletions.
There are no files selected for viewing
This file contains 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
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
using Tingle.AzureCleaner; | ||
using Tingle.EventBus; | ||
|
||
namespace Tingle.AzureCleaner; | ||
|
||
internal class AzdoCleanupEvent | ||
{ | ||
public required int PullRequestId { get; init; } | ||
public required string RemoteUrl { get; init; } | ||
public required string RawProjectUrl { get; init; } | ||
} | ||
|
||
internal class ProcessAzdoCleanupEventConsumer(AzureCleaner cleaner) : IEventConsumer<AzdoCleanupEvent> | ||
{ | ||
public async Task ConsumeAsync(EventContext<AzdoCleanupEvent> context, CancellationToken cancellationToken) | ||
{ | ||
var evt = context.Event; | ||
await cleaner.HandleAsync(prId: evt.PullRequestId, | ||
remoteUrl: evt.RemoteUrl, | ||
rawProjectUrl: evt.RawProjectUrl, | ||
cancellationToken: cancellationToken); | ||
} | ||
} |
66 changes: 66 additions & 0 deletions
66
Tingle.AzureCleaner/Extensions/IEndpointRouteBuilderExtensions.cs
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
using Microsoft.AspNetCore.Mvc; | ||
using MiniValidation; | ||
using System.Text.Json; | ||
using Tingle.AzureCleaner; | ||
using Tingle.EventBus; | ||
|
||
namespace Microsoft.AspNetCore.Builder; | ||
|
||
internal static class IEndpointRouteBuilderExtensions | ||
{ | ||
public static IEndpointConventionBuilder MapWebhooksAzure(this IEndpointRouteBuilder builder) | ||
{ | ||
return builder.MapPost("/webhooks/azure", async (ILoggerFactory loggerFactory, IEventPublisher publisher, [FromBody] AzdoEvent model) => | ||
{ | ||
var logger = loggerFactory.CreateLogger("Tingle.AzureCleaner.Webhooks"); | ||
if (!MiniValidator.TryValidate(model, out var errors)) return Results.ValidationProblem(errors); | ||
|
||
var type = model.EventType; | ||
logger.LogInformation("Received {EventType} notification {NotificationId} on subscription {SubscriptionId}", | ||
type, | ||
model.NotificationId, | ||
model.SubscriptionId?.Replace(Environment.NewLine, "")); | ||
|
||
if (type is AzureDevOpsEventType.GitPullRequestUpdated) | ||
{ | ||
var resource = JsonSerializer.Deserialize<AzureDevOpsEventPullRequestResource>(model.Resource)!; | ||
var prId = resource.PullRequestId; | ||
var status = resource.Status; | ||
|
||
/* | ||
* Only the PR status is considered. Adding consideration for merge status | ||
* results is more combinations that may be unnecessary. | ||
* For example: status = abandoned, mergeStatus = conflict | ||
*/ | ||
string[] targetStatuses = ["completed", "abandoned", "draft"]; | ||
if (targetStatuses.Contains(status, StringComparer.OrdinalIgnoreCase)) | ||
{ | ||
var rawProjectUrl = resource.Repository?.Project?.Url ?? throw new InvalidOperationException("Project URL should not be null"); | ||
var remoteUrl = resource.Repository?.RemoteUrl ?? throw new InvalidOperationException("RemoteUrl should not be null"); | ||
var evt = new AzdoCleanupEvent | ||
{ | ||
PullRequestId = prId, | ||
RemoteUrl = remoteUrl, | ||
RawProjectUrl = rawProjectUrl, | ||
}; | ||
// if the PR closes immediately after the resources are created they may not be removed | ||
// adding a delay allows the changes in the cloud provider to have propagated | ||
var delay = TimeSpan.FromMinutes(1); | ||
await publisher.PublishAsync(@event: evt, delay: delay); | ||
} | ||
else | ||
{ | ||
logger.LogTrace("PR {PullRequestId} was updated but the status didn't match. Status '{Status}'", prId, status); | ||
} | ||
} | ||
else | ||
{ | ||
logger.LogWarning("Events of type {EventType} are not supported." + | ||
" If you wish to support them you can clone the repository or contribute a PR at https://github.com/tinglesoftware/azure-resources-cleaner", | ||
type); | ||
} | ||
|
||
return Results.Ok(); | ||
}); | ||
} | ||
} |
48 changes: 48 additions & 0 deletions
48
Tingle.AzureCleaner/Extensions/IServiceCollectionExtensions.cs
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
using Azure.Identity; | ||
using Tingle.AzureCleaner; | ||
|
||
namespace Microsoft.Extensions.DependencyInjection; | ||
|
||
internal enum EventBusTransportKind { InMemory, ServiceBus, QueueStorage, } | ||
|
||
internal static class IServiceCollectionExtensions | ||
{ | ||
public static IServiceCollection AddCleaner(this IServiceCollection services, IConfiguration configuration) | ||
{ | ||
services.AddMemoryCache(); | ||
services.Configure<AzureCleanerOptions>(configuration); | ||
services.AddSingleton<AzureCleaner>(); | ||
|
||
return services; | ||
} | ||
|
||
public static IServiceCollection AddEventBus(this IServiceCollection services, IConfiguration configuration, Action<EventBusBuilder>? setupAction = null) | ||
{ | ||
var selectedTransport = configuration.GetValue<EventBusTransportKind?>("EventBus:SelectedTransport"); | ||
services.AddEventBus(builder => | ||
{ | ||
// Setup consumers | ||
builder.AddConsumer<ProcessAzdoCleanupEventConsumer>(); | ||
|
||
// Setup transports | ||
var credential = new DefaultAzureCredential(); | ||
if (selectedTransport is EventBusTransportKind.ServiceBus) | ||
{ | ||
builder.AddAzureServiceBusTransport( | ||
options => ((AzureServiceBusTransportCredentials)options.Credentials).TokenCredential = credential); | ||
} | ||
else if (selectedTransport is EventBusTransportKind.QueueStorage) | ||
{ | ||
builder.AddAzureQueueStorageTransport( | ||
options => ((AzureQueueStorageTransportCredentials)options.Credentials).TokenCredential = credential); | ||
} | ||
else if (selectedTransport is EventBusTransportKind.InMemory) | ||
{ | ||
builder.AddInMemoryTransport(); | ||
} | ||
|
||
setupAction?.Invoke(builder); | ||
}); | ||
return services; | ||
} | ||
} |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,168 +1,80 @@ | ||
using AspNetCore.Authentication.Basic; | ||
using Microsoft.AspNetCore.Diagnostics.HealthChecks; | ||
using Microsoft.AspNetCore.Mvc; | ||
using MiniValidation; | ||
using System.Text.Json; | ||
using System.CommandLine; | ||
using System.CommandLine.Builder; | ||
using System.CommandLine.Hosting; | ||
using System.CommandLine.NamingConventionBinder; | ||
using System.CommandLine.Parsing; | ||
using Tingle.AzureCleaner; | ||
using Tingle.EventBus; | ||
|
||
var builder = WebApplication.CreateBuilder(args); | ||
|
||
// Add Serilog | ||
builder.Services.AddSerilog(builder => | ||
if (!builder.Configuration.GetValue<bool>("CLI")) | ||
{ | ||
builder.ConfigureSensitiveDataMasking(options => | ||
{ | ||
options.ExcludeProperties.AddRange([ | ||
"ProjectUrl", | ||
"RemoteUrl", | ||
"ResourceId", | ||
]); | ||
}); | ||
}); | ||
|
||
builder.Services.AddApplicationInsightsTelemetry(); | ||
builder.Services.AddAuthentication() | ||
.AddBasic<BasicUserValidationService>(options => options.Realm = "AzureCleaner"); | ||
|
||
builder.Services.AddAuthorization(options => | ||
{ | ||
// By default, all incoming requests will be authorized according to the default policy. | ||
options.FallbackPolicy = options.DefaultPolicy; | ||
}); | ||
|
||
builder.Services.AddCleaner(builder.Configuration.GetSection("Cleaner")); | ||
|
||
// Add event bus | ||
var selectedTransport = builder.Configuration.GetValue<EventBusTransportKind?>("EventBus:SelectedTransport"); | ||
builder.Services.AddEventBus(builder => | ||
builder.Services.AddApplicationInsightsTelemetry() | ||
.AddSerilog(sb => sb.ConfigureSensitiveDataMasking(o => o.ExcludeProperties.AddRange(["ProjectUrl", "RemoteUrl", "ResourceId"]))) | ||
.AddCleaner(builder.Configuration.GetSection("Cleaner")) | ||
.AddEventBus(builder.Configuration) | ||
.AddHealthChecks(); | ||
|
||
builder.Services.AddAuthentication().AddBasic<BasicUserValidationService>(options => options.Realm = "AzureCleaner"); | ||
builder.Services.AddAuthorization(options => options.FallbackPolicy = options.DefaultPolicy); // By default, all incoming requests will be authorized according to the default policy. | ||
|
||
var app = builder.Build(); | ||
|
||
app.UseRouting(); | ||
app.UseAuthentication(); | ||
app.UseAuthorization(); | ||
app.MapHealthChecks("/health").AllowAnonymous(); | ||
app.MapHealthChecks("/liveness", new HealthCheckOptions { Predicate = _ => false, }).AllowAnonymous(); | ||
app.MapWebhooksAzure(); | ||
|
||
await app.RunAsync(); | ||
return 0; | ||
} | ||
else | ||
{ | ||
// Setup consumers | ||
builder.AddConsumer<ProcessAzdoCleanupEventConsumer>(); | ||
|
||
// Setup transports | ||
var credential = new Azure.Identity.DefaultAzureCredential(); | ||
if (selectedTransport is EventBusTransportKind.ServiceBus) | ||
{ | ||
builder.AddAzureServiceBusTransport( | ||
options => ((AzureServiceBusTransportCredentials)options.Credentials).TokenCredential = credential); | ||
} | ||
else if (selectedTransport is EventBusTransportKind.QueueStorage) | ||
var root = new RootCommand("Cleanup tool for Azure resources based on Azure DevOps PRs") | ||
{ | ||
builder.AddAzureQueueStorageTransport( | ||
options => ((AzureQueueStorageTransportCredentials)options.Credentials).TokenCredential = credential); | ||
} | ||
else if (selectedTransport is EventBusTransportKind.InMemory) | ||
new Option<int>(["-p", "--pr", "--pull-request-id"], "Identifier of the pull request.") { IsRequired = true, }, | ||
new Option<string?>(["--remote", "--remote-url"], "Remote URL of the Azure DevOps repository."), | ||
new Option<string?>(["--project", "--project-url"], "Project URL. Overrides the remote URL when provided."), | ||
}; | ||
root.Handler = CommandHandler.Create(async (IHost host, int pullRequestId, string? remoteUrl, string? projectUrl) => | ||
{ | ||
builder.AddInMemoryTransport(); | ||
} | ||
}); | ||
|
||
// Add health checks | ||
builder.Services.AddHealthChecks(); | ||
|
||
var app = builder.Build(); | ||
|
||
app.UseRouting(); | ||
|
||
app.UseAuthentication(); | ||
app.UseAuthorization(); | ||
|
||
app.MapHealthChecks("/health").AllowAnonymous(); | ||
app.MapHealthChecks("/liveness", new HealthCheckOptions { Predicate = _ => false, }).AllowAnonymous(); | ||
|
||
app.MapWebhooksAzure(); | ||
|
||
await app.RunAsync(); | ||
|
||
internal enum EventBusTransportKind { InMemory, ServiceBus, QueueStorage, } | ||
|
||
internal static class IServiceCollectionExtensions | ||
{ | ||
public static IServiceCollection AddCleaner(this IServiceCollection services, IConfiguration configuration) | ||
{ | ||
services.AddMemoryCache(); | ||
services.Configure<AzureCleanerOptions>(configuration); | ||
services.AddSingleton<AzureCleaner>(); | ||
|
||
return services; | ||
} | ||
} | ||
var cleaner = host.Services.GetRequiredService<AzureCleaner>(); | ||
await cleaner.HandleAsync(prId: pullRequestId, remoteUrl: remoteUrl, rawProjectUrl: projectUrl); | ||
}); | ||
|
||
internal static class IEndpointRouteBuilderExtensions | ||
{ | ||
public static IEndpointConventionBuilder MapWebhooksAzure(this IEndpointRouteBuilder builder) | ||
{ | ||
return builder.MapPost("/webhooks/azure", async (ILoggerFactory loggerFactory, IEventPublisher publisher, [FromBody] AzdoEvent model) => | ||
var clb = new CommandLineBuilder(root) | ||
.UseHost(_ => Host.CreateDefaultBuilder(args), host => | ||
{ | ||
var logger = loggerFactory.CreateLogger("Tingle.AzureCleaner.Webhooks"); | ||
if (!MiniValidator.TryValidate(model, out var errors)) return Results.ValidationProblem(errors); | ||
|
||
var type = model.EventType; | ||
logger.LogInformation("Received {EventType} notification {NotificationId} on subscription {SubscriptionId}", | ||
type, | ||
model.NotificationId, | ||
model.SubscriptionId?.Replace(Environment.NewLine, "")); | ||
|
||
if (type is AzureDevOpsEventType.GitPullRequestUpdated) | ||
host.ConfigureAppConfiguration((context, builder) => | ||
{ | ||
var resource = JsonSerializer.Deserialize<AzureDevOpsEventPullRequestResource>(model.Resource)!; | ||
var prId = resource.PullRequestId; | ||
var status = resource.Status; | ||
|
||
/* | ||
* Only the PR status is considered. Adding consideration for merge status | ||
* results is more combinations that may be unnecessary. | ||
* For example: status = abandoned, mergeStatus = conflict | ||
*/ | ||
string[] targetStatuses = ["completed", "abandoned", "draft"]; | ||
if (targetStatuses.Contains(status, StringComparer.OrdinalIgnoreCase)) | ||
{ | ||
var rawProjectUrl = resource.Repository?.Project?.Url ?? throw new InvalidOperationException("Project URL should not be null"); | ||
var remoteUrl = resource.Repository?.RemoteUrl ?? throw new InvalidOperationException("RemoteUrl should not be null"); | ||
var evt = new AzdoCleanupEvent | ||
{ | ||
PullRequestId = prId, | ||
RemoteUrl = remoteUrl, | ||
RawProjectUrl = rawProjectUrl, | ||
}; | ||
// if the PR closes immediately after the resources are created they may not be removed | ||
// adding a delay allows the changes in the cloud provider to have propagated | ||
var delay = TimeSpan.FromMinutes(1); | ||
await publisher.PublishAsync(@event: evt, delay: delay); | ||
} | ||
else | ||
builder.AddInMemoryCollection(new Dictionary<string, string?> | ||
{ | ||
logger.LogTrace("PR {PullRequestId} was updated but the status didn't match. Status '{Status}'", prId, status); | ||
} | ||
} | ||
else | ||
{ | ||
logger.LogWarning("Events of type {EventType} are not supported." + | ||
" If you wish to support them you can clone the repository or contribute a PR at https://github.com/tinglesoftware/azure-resources-cleaner", | ||
type); | ||
} | ||
["Logging:LogLevel:Default"] = "Information", | ||
["Logging:LogLevel:Microsoft"] = "Warning", | ||
["Logging:LogLevel:Microsoft.Hosting.Lifetime"] = "Warning", | ||
|
||
return Results.Ok(); | ||
}); | ||
} | ||
} | ||
["Logging:LogLevel:Tingle.AzureCleaner"] = "Trace", | ||
|
||
internal class AzdoCleanupEvent | ||
{ | ||
public required int PullRequestId { get; init; } | ||
public required string RemoteUrl { get; init; } | ||
public required string RawProjectUrl { get; init; } | ||
} | ||
//["Logging:Console:FormatterName"] = "Tingle", | ||
["Logging:Console:FormatterOptions:SingleLine"] = "False", | ||
["Logging:Console:FormatterOptions:IncludeCategory"] = "True", | ||
["Logging:Console:FormatterOptions:IncludeEventId"] = "True", | ||
["Logging:Console:FormatterOptions:TimestampFormat"] = "HH:mm:ss ", | ||
}); | ||
}); | ||
|
||
internal class ProcessAzdoCleanupEventConsumer(AzureCleaner cleaner) : IEventConsumer<AzdoCleanupEvent> | ||
{ | ||
public async Task ConsumeAsync(EventContext<AzdoCleanupEvent> context, CancellationToken cancellationToken) | ||
{ | ||
var evt = context.Event; | ||
await cleaner.HandleAsync(prId: evt.PullRequestId, | ||
remoteUrl: evt.RemoteUrl, | ||
rawProjectUrl: evt.RawProjectUrl, | ||
cancellationToken: cancellationToken); | ||
} | ||
host.ConfigureServices(services => | ||
{ | ||
services.AddCleaner(builder.Configuration.GetSection("Cleaner")); | ||
}); | ||
}) | ||
.UseDefaults(); | ||
|
||
// Parse the incoming args and invoke the handler | ||
var parser = clb.Build(); | ||
return await parser.InvokeAsync(args); | ||
} |
This file contains 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
Oops, something went wrong.