Skip to content

Commit

Permalink
Add CLI functionality to allow invoking via docker (#137)
Browse files Browse the repository at this point in the history
  • Loading branch information
mburumaxwell authored Mar 8, 2024
1 parent aa4360b commit aec752c
Show file tree
Hide file tree
Showing 7 changed files with 206 additions and 151 deletions.
2 changes: 2 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ updates:
interval: "weekly"
time: "02:00"
groups:
command-line:
patterns: ["System.CommandLine*"]
event-bus:
patterns: ["Tingle.EventBus.*"]
microsoft:
Expand Down
23 changes: 23 additions & 0 deletions Tingle.AzureCleaner/AzdoCleanupEvent.cs
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 Tingle.AzureCleaner/Extensions/IEndpointRouteBuilderExtensions.cs
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 Tingle.AzureCleaner/Extensions/IServiceCollectionExtensions.cs
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;
}
}
212 changes: 62 additions & 150 deletions Tingle.AzureCleaner/Program.cs
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);
}
4 changes: 3 additions & 1 deletion Tingle.AzureCleaner/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
"profiles": {
"Tingle.AzureCleaner": {
"commandName": "Project",
//"commandLineArgs": "Tingle.AzureCleaner --pr 12",
"launchBrowser": true,
"launchUrl": "health",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
"ASPNETCORE_ENVIRONMENT": "Development",
//"CLI": "true"
},
"applicationUrl": "https://localhost:44392;http://localhost:59271"
},
Expand Down
Loading

0 comments on commit aec752c

Please sign in to comment.