From 5f63c031df2c92add9c595f634f425545cdaf4fb Mon Sep 17 00:00:00 2001 From: Manuel Pfemeter Date: Wed, 30 Jan 2019 10:24:00 +0100 Subject: [PATCH] introduced cancellation to CLI and Azure Function stack --- src/aggregator-cli/CommandBase.cs | 18 +-- src/aggregator-cli/ContextBuilder.cs | 11 +- .../Instances/AggregatorInstances.cs | 48 ++++---- .../Instances/ConfigureInstanceCommand.cs | 10 +- .../Instances/FunctionRuntimePackage.cs | 31 +++--- .../Instances/InstallInstanceCommand.cs | 7 +- .../Instances/ListInstancesCommand.cs | 29 ++--- .../Instances/StreamLogsCommand.cs | 7 +- .../Instances/UninstallInstanceCommand.cs | 10 +- src/aggregator-cli/Kudu/KuduApi.cs | 36 +++--- src/aggregator-cli/Logon/AzureLogon.cs | 4 +- src/aggregator-cli/Logon/DevOpsLogon.cs | 5 +- src/aggregator-cli/Logon/LogonAzureCommand.cs | 7 +- .../Logon/LogonDevOpsCommand.cs | 9 +- .../Mappings/AggregatorMappings.cs | 11 +- .../Mappings/ListMappingsCommand.cs | 6 +- src/aggregator-cli/Mappings/MapRuleCommand.cs | 8 +- .../Mappings/UnmapRuleCommand.cs | 5 +- src/aggregator-cli/Program.cs | 104 ++++++++++-------- src/aggregator-cli/Rules/AddRuleCommand.cs | 7 +- src/aggregator-cli/Rules/AggregatorRules.cs | 69 ++++++------ .../Rules/ConfigureRuleCommand.cs | 7 +- src/aggregator-cli/Rules/InvokeRuleCommand.cs | 9 +- src/aggregator-cli/Rules/ListRulesCommand.cs | 10 +- src/aggregator-cli/Rules/RemoveRuleCommand.cs | 7 +- src/aggregator-cli/Rules/UpdateRuleCommand.cs | 7 +- src/aggregator-cli/Rules/run.csx | 7 +- src/aggregator-cli/TestCommand.cs | 7 +- src/aggregator-function/AssemblyInfo.cs | 3 +- .../AzureFunctionHandler.cs | 47 ++++---- .../AzureFunctionHandlerExtension.cs | 20 ++++ src/aggregator-function/RuleWrapper.cs | 37 +++++-- src/aggregator-ruleng/BatchProxy.cs | 13 ++- src/aggregator-ruleng/RuleEngine.cs | 9 +- src/aggregator-ruleng/WorkItemStore.cs | 43 +++++--- src/unittests-ruleng/RuleTests.cs | 15 +-- src/unittests-ruleng/WorkItemStoreTests.cs | 6 +- 37 files changed, 397 insertions(+), 292 deletions(-) create mode 100644 src/aggregator-function/AzureFunctionHandlerExtension.cs diff --git a/src/aggregator-cli/CommandBase.cs b/src/aggregator-cli/CommandBase.cs index 3f173a27..b6199df7 100644 --- a/src/aggregator-cli/CommandBase.cs +++ b/src/aggregator-cli/CommandBase.cs @@ -5,13 +5,14 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli { abstract class CommandBase { - ILogger logger = null; + ILogger logger; // Omitting long name, defaults to name of property, ie "--verbose" [Option('v', "verbose", Default = false, HelpText = "Prints all messages to standard output.")] @@ -19,11 +20,11 @@ abstract class CommandBase protected ContextBuilder Context => new ContextBuilder(logger); - internal abstract Task RunAsync(); + internal abstract Task RunAsync(CancellationToken cancellationToken); - internal int Run() + internal int Run(CancellationToken cancellationToken) { - this.logger = new ConsoleLogger(Verbose); + logger = new ConsoleLogger(Verbose); try { var title = GetCustomAttribute(); @@ -35,16 +36,19 @@ internal int Run() // Hello World logger.WriteInfo($"{title.Title} v{infoVersion.InformationalVersion} (build: {fileVersion.Version} {config.Configuration}) (c) {copyright.Copyright}"); - var t = this.RunAsync(); - t.Wait(); + var t = RunAsync(cancellationToken); + t.Wait(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); int rc = t.Result; if (rc != 0) { logger.WriteError("Failed!"); - } else + } + else { logger.WriteSuccess("Succeeded"); } + return rc; } catch (Exception ex) diff --git a/src/aggregator-cli/ContextBuilder.cs b/src/aggregator-cli/ContextBuilder.cs index c834536c..5cc438ed 100644 --- a/src/aggregator-cli/ContextBuilder.cs +++ b/src/aggregator-cli/ContextBuilder.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -34,12 +35,14 @@ internal ContextBuilder WithAzureLogon() azureLogon = true; return this; } + internal ContextBuilder WithDevOpsLogon() { devopsLogon = true; return this; } - internal async Task Build() + + internal async Task BuildAsync(CancellationToken cancellationToken) { IAzure azure = null; VssConnection devops = null; @@ -53,7 +56,8 @@ internal async Task Build() string msg = TranslateResult(reason); throw new ApplicationException(string.Format(msg, "Azure","logon.azure")); } - azure = await connection.LogonAsync(); + + azure = connection.Logon(); logger.WriteInfo($"Connected to subscription {azure.SubscriptionId}"); } @@ -66,7 +70,8 @@ internal async Task Build() string msg = TranslateResult(reason); throw new ApplicationException(string.Format(msg, "Azure DevOps", "logon.ado")); } - devops = await connection.LogonAsync(); + + devops = await connection.LogonAsync(cancellationToken); logger.WriteInfo($"Connected to {devops.Uri.Host}"); } diff --git a/src/aggregator-cli/Instances/AggregatorInstances.cs b/src/aggregator-cli/Instances/AggregatorInstances.cs index baa1170e..f5d9e0c6 100644 --- a/src/aggregator-cli/Instances/AggregatorInstances.cs +++ b/src/aggregator-cli/Instances/AggregatorInstances.cs @@ -6,8 +6,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net.Http; using System.Reflection; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -23,10 +23,10 @@ public AggregatorInstances(IAzure azure, ILogger logger) this.logger = logger; } - public async Task> ListAllAsync() + public async Task> ListAllAsync(CancellationToken cancellationToken) { var runtime = new FunctionRuntimePackage(logger); - var rgs = await azure.ResourceGroups.ListAsync(); + var rgs = await azure.ResourceGroups.ListAsync(cancellationToken: cancellationToken); var filter = rgs .Where(rg => rg.Name.StartsWith(InstanceName.ResourceGroupInstancePrefix)); var result = new List(); @@ -36,16 +36,16 @@ public async Task> ListAllAsync() result.Add(new InstanceOutputData( name.PlainName, rg.RegionName, - await runtime.GetDeployedRuntimeVersion(name, azure)) + await runtime.GetDeployedRuntimeVersion(name, azure, cancellationToken)) ); } return result; } - public async Task> ListByLocationAsync(string location) + public async Task> ListByLocationAsync(string location, CancellationToken cancellationToken) { var runtime = new FunctionRuntimePackage(logger); - var rgs = await azure.ResourceGroups.ListAsync(); + var rgs = await azure.ResourceGroups.ListAsync(cancellationToken: cancellationToken); var filter = rgs.Where(rg => rg.Name.StartsWith(InstanceName.ResourceGroupInstancePrefix) && rg.RegionName.CompareTo(location) == 0); @@ -56,36 +56,36 @@ public async Task> ListByLocationAsync(string locati result.Add(new InstanceOutputData( name.PlainName, rg.RegionName, - await runtime.GetDeployedRuntimeVersion(name, azure)) + await runtime.GetDeployedRuntimeVersion(name, azure, cancellationToken)) ); } return result; } - internal async Task> ListInResourceGroupAsync(string resourceGroup) + internal async Task> ListInResourceGroupAsync(string resourceGroup, CancellationToken cancellationToken) { var runtime = new FunctionRuntimePackage(logger); - var apps = await azure.AppServices.FunctionApps.ListByResourceGroupAsync(resourceGroup); + var apps = await azure.AppServices.FunctionApps.ListByResourceGroupAsync(resourceGroup, cancellationToken: cancellationToken); var result = new List(); foreach (var app in apps) { + cancellationToken.ThrowIfCancellationRequested(); var name = InstanceName.FromFunctionAppName(app.Name, resourceGroup); result.Add(new InstanceOutputData( name.PlainName, app.Region.Name, - await runtime.GetDeployedRuntimeVersion(name, azure)) + await runtime.GetDeployedRuntimeVersion(name, azure, cancellationToken)) ); } return result; } - - internal async Task Add(InstanceName instance, string location, string requiredVersion) + internal async Task AddAsync(InstanceName instance, string location, string requiredVersion, CancellationToken cancellationToken) { string rgName = instance.ResourceGroupName; logger.WriteVerbose($"Checking if Resource Group {rgName} already exists"); - if (!await azure.ResourceGroups.ContainAsync(rgName)) + if (!await azure.ResourceGroups.ContainAsync(rgName, cancellationToken)) { if (instance.IsCustom) { @@ -131,7 +131,7 @@ await azure.ResourceGroups .WithTemplate(armTemplateString) .WithParameters(templateParams) .WithMode(DeploymentMode.Incremental) - .CreateAsync(); + .CreateAsync(cancellationToken); // poll const int PollIntervalInSeconds = 3; @@ -143,20 +143,20 @@ await azure.ResourceGroups SdkContext.DelayProvider.Delay(PollIntervalInSeconds * 1000); totalDelay += PollIntervalInSeconds; logger.WriteVerbose($"Deployment running ({totalDelay}s)"); - await deployment.RefreshAsync(); + await deployment.RefreshAsync(cancellationToken); } logger.WriteInfo($"Deployment {deployment.ProvisioningState}"); // check runtime package var package = new FunctionRuntimePackage(logger); - bool ok = await package.UpdateVersion(requiredVersion, instance, azure); + bool ok = await package.UpdateVersionAsync(requiredVersion, instance, azure, cancellationToken); if (ok) { var devopsLogonData = DevOpsLogon.Load().connection; if (devopsLogonData.Mode == DevOpsTokenType.PAT) { logger.WriteVerbose($"Saving Azure DevOps token"); - ok = await ChangeAppSettings(instance, devopsLogonData, SaveMode.Default); + ok = await ChangeAppSettingsAsync(instance, devopsLogonData, SaveMode.Default, cancellationToken); if (ok) { logger.WriteInfo($"Azure DevOps token saved"); @@ -175,14 +175,14 @@ await azure.ResourceGroups return ok; } - internal async Task ChangeAppSettings(InstanceName instance, DevOpsLogon devopsLogonData, SaveMode saveMode) + internal async Task ChangeAppSettingsAsync(InstanceName instance, DevOpsLogon devopsLogonData, SaveMode saveMode, CancellationToken cancellationToken) { var webFunctionApp = await azure .AppServices .WebApps .GetByResourceGroupAsync( instance.ResourceGroupName, - instance.FunctionAppName); + instance.FunctionAppName, cancellationToken); var configuration = new AggregatorConfiguration { DevOpsTokenType = devopsLogonData.Mode, @@ -193,7 +193,7 @@ internal async Task ChangeAppSettings(InstanceName instance, DevOpsLogon d return true; } - internal async Task Remove(InstanceName instance, string location) + internal async Task RemoveAsync(InstanceName instance, string location) { string rgName = instance.ResourceGroupName; logger.WriteVerbose($"Searching instance {instance.PlainName}..."); @@ -232,14 +232,14 @@ internal async Task Remove(InstanceName instance, string location) return true; } - internal async Task ChangeAppSettings(InstanceName instance, string location, SaveMode saveMode) + internal async Task ChangeAppSettingsAsync(InstanceName instance, string location, SaveMode saveMode, CancellationToken cancellationToken) { bool ok; var devopsLogonData = DevOpsLogon.Load().connection; if (devopsLogonData.Mode == DevOpsTokenType.PAT) { logger.WriteVerbose($"Saving Azure DevOps token"); - ok = await ChangeAppSettings(instance, devopsLogonData, saveMode); + ok = await ChangeAppSettingsAsync(instance, devopsLogonData, saveMode, cancellationToken); logger.WriteInfo($"Azure DevOps token saved"); } else @@ -251,7 +251,7 @@ internal async Task ChangeAppSettings(InstanceName instance, string locati } - internal async Task StreamLogsAsync(InstanceName instance) + internal async Task StreamLogsAsync(InstanceName instance, CancellationToken cancellationToken) { var kudu = new KuduApi(instance, azure, logger); logger.WriteVerbose($"Connecting to {instance.PlainName}..."); @@ -259,7 +259,7 @@ internal async Task StreamLogsAsync(InstanceName instance) // Main takes care of resetting color Console.ForegroundColor = ConsoleColor.Green; - await kudu.StreamLogsAsync(Console.Out); + await kudu.StreamLogsAsync(Console.Out, cancellationToken); return true; } diff --git a/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs b/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs index 71229107..b1952ae6 100644 --- a/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs +++ b/src/aggregator-cli/Instances/ConfigureInstanceCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -23,26 +24,27 @@ internal class ConfigureInstanceCommand : CommandBase [Option('m', "saveMode", SetName = "save", Required = false, HelpText = "Save behaviour.")] public SaveMode SaveMode { get; set; } - + // TODO add --swap.slot to support App Service Deployment Slots - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() .WithDevOpsLogon() // need the token, so we can save it in the app settings - .Build(); + .BuildAsync(cancellationToken); var instances = new AggregatorInstances(context.Azure, context.Logger); var instance = new InstanceName(Name, ResourceGroup); bool ok = false; if (Authentication) { - ok = await instances.ChangeAppSettings(instance, Location, SaveMode); + ok = await instances.ChangeAppSettingsAsync(instance, Location, SaveMode, cancellationToken); } else { context.Logger.WriteError($"Unsupported command option(s)"); } + return ok ? 0 : 1; } } diff --git a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs index 270826a4..e0cd2b8a 100644 --- a/src/aggregator-cli/Instances/FunctionRuntimePackage.cs +++ b/src/aggregator-cli/Instances/FunctionRuntimePackage.cs @@ -8,6 +8,7 @@ using System.Net; using System.Net.Http; using System.Reflection; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -29,7 +30,7 @@ internal FunctionRuntimePackage(ILogger logger) private string RuntimePackageFile => "FunctionRuntime.zip"; - internal async Task UpdateVersion(string requiredVersion, InstanceName instance, IAzure azure) + internal async Task UpdateVersionAsync(string requiredVersion, InstanceName instance, IAzure azure, CancellationToken cancellationToken) { string tag = string.IsNullOrWhiteSpace(requiredVersion) ? "latest" @@ -40,7 +41,7 @@ internal async Task UpdateVersion(string requiredVersion, InstanceName ins : requiredVersion); logger.WriteVerbose($"Checking runtime package versions in GitHub"); - (string rel_name, DateTimeOffset? rel_when, string rel_url) = await FindVersionInGitHub(tag); + (string rel_name, DateTimeOffset? rel_when, string rel_url) = await FindVersionInGitHubAsync(tag); if (string.IsNullOrEmpty(rel_name)) { logger.WriteError($"Requested runtime {requiredVersion} version does not exists."); @@ -54,14 +55,14 @@ internal async Task UpdateVersion(string requiredVersion, InstanceName ins logger.WriteVerbose($"Cached Runtime package version is {localRuntimeVer}."); // TODO check the uploaded version before overwriting? - SemVersion uploadedRuntimeVer = await GetDeployedRuntimeVersion(instance, azure); + SemVersion uploadedRuntimeVer = await GetDeployedRuntimeVersion(instance, azure, cancellationToken); if (requiredRuntimeVer > uploadedRuntimeVer || localRuntimeVer > uploadedRuntimeVer) { if (requiredRuntimeVer > localRuntimeVer) { logger.WriteVerbose($"Downloading runtime package {rel_name}"); - await Download(rel_url); + await DownloadAsync(rel_url, cancellationToken); logger.WriteInfo($"Runtime package downloaded."); } else @@ -70,7 +71,7 @@ internal async Task UpdateVersion(string requiredVersion, InstanceName ins } logger.WriteVerbose($"Uploading runtime package to {instance.DnsHostName}"); - bool ok = await UploadRuntimeZip(instance, azure); + bool ok = await UploadRuntimeZip(instance, azure, cancellationToken); if (ok) { logger.WriteInfo($"Runtime package uploaded to {instance.PlainName}."); @@ -88,14 +89,14 @@ internal async Task UpdateVersion(string requiredVersion, InstanceName ins } } - internal async Task GetDeployedRuntimeVersion(InstanceName instance, IAzure azure) + internal async Task GetDeployedRuntimeVersion(InstanceName instance, IAzure azure, CancellationToken cancellationToken) { logger.WriteVerbose($"Retrieving functions runtime from {instance.PlainName} app"); SemVersion uploadedRuntimeVer; var kudu = new KuduApi(instance, azure, logger); using (var client = new HttpClient()) - using (var request = await kudu.GetRequestAsync(HttpMethod.Get, $"api/vfs/site/wwwroot/aggregator-manifest.ini")) - using (var response = await client.SendAsync(request)) + using (var request = await kudu.GetRequestAsync(HttpMethod.Get, $"api/vfs/site/wwwroot/aggregator-manifest.ini", cancellationToken)) + using (var response = await client.SendAsync(request, cancellationToken)) { string manifest = await response.Content.ReadAsStringAsync(); @@ -133,7 +134,7 @@ private async Task GetLocalPackageVersionAsync(string runtimePackage return new SemVersion(0, 0); } - private async Task<(string name, DateTimeOffset? when, string url)> FindVersionInGitHub(string tag = "latest") + private async Task<(string name, DateTimeOffset? when, string url)> FindVersionInGitHubAsync(string tag = "latest") { var githubClient = new GitHubClient(new ProductHeaderValue("aggregator-cli", infoVersion)); var releases = await githubClient.Repository.Release.GetAll("tfsaggregator", "aggregator-cli"); @@ -151,18 +152,20 @@ private async Task GetLocalPackageVersionAsync(string runtimePackage return (name: release.Name, when: release.PublishedAt, url: asset.BrowserDownloadUrl); } - private async Task Download(string downloadUrl) + private async Task DownloadAsync(string downloadUrl, CancellationToken cancellationToken) { using (var httpClient = new WebClient()) + using (cancellationToken.Register(httpClient.CancelAsync)) { await httpClient.DownloadFileTaskAsync(downloadUrl, RuntimePackageFile); } + return RuntimePackageFile; } - private async Task UploadRuntimeZip(InstanceName instance, IAzure azure) + private async Task UploadRuntimeZip(InstanceName instance, IAzure azure, CancellationToken cancellationToken) { - var zipContent = File.ReadAllBytes(RuntimePackageFile); + var zipContent = await File.ReadAllBytesAsync(RuntimePackageFile, cancellationToken); var kudu = new KuduApi(instance, azure, logger); // POST /api/zipdeploy?isAsync=true // Deploy from zip asynchronously. The Location header of the response will contain a link to a pollable deployment status. @@ -170,10 +173,10 @@ private async Task UploadRuntimeZip(InstanceName instance, IAzure azure) using (var client = new HttpClient()) { client.Timeout = TimeSpan.FromMinutes(60); - using (var request = await kudu.GetRequestAsync(HttpMethod.Post, $"api/zipdeploy")) + using (var request = await kudu.GetRequestAsync(HttpMethod.Post, $"api/zipdeploy", cancellationToken)) { request.Content = body; - using (var response = await client.SendAsync(request)) + using (var response = await client.SendAsync(request, cancellationToken)) { bool ok = response.IsSuccessStatusCode; if (!ok) diff --git a/src/aggregator-cli/Instances/InstallInstanceCommand.cs b/src/aggregator-cli/Instances/InstallInstanceCommand.cs index 4c502698..cb41af00 100644 --- a/src/aggregator-cli/Instances/InstallInstanceCommand.cs +++ b/src/aggregator-cli/Instances/InstallInstanceCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -21,15 +22,15 @@ class InstallInstanceCommand : CommandBase [Option("requiredVersion", Required = false, HelpText = "Version of Aggregator Runtime required.")] public string RequiredVersion { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() .WithDevOpsLogon() // need the token, so we can save it in the app settings - .Build(); + .BuildAsync(cancellationToken); var instances = new AggregatorInstances(context.Azure, context.Logger); var instance = new InstanceName(Name, ResourceGroup); - bool ok = await instances.Add(instance, Location, RequiredVersion); + bool ok = await instances.AddAsync(instance, Location, RequiredVersion, cancellationToken); return ok ? 0 : 1; } } diff --git a/src/aggregator-cli/Instances/ListInstancesCommand.cs b/src/aggregator-cli/Instances/ListInstancesCommand.cs index 2c33f9da..ccee541e 100644 --- a/src/aggregator-cli/Instances/ListInstancesCommand.cs +++ b/src/aggregator-cli/Instances/ListInstancesCommand.cs @@ -1,6 +1,5 @@ using CommandLine; -using System; -using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -14,33 +13,33 @@ class ListInstancesCommand : CommandBase [Option('g', "resourceGroup", Required = false, Default = "", HelpText = "Azure Resource Group hosting the Aggregator instances.")] public string ResourceGroup { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() - .Build(); + .BuildAsync(cancellationToken); var instances = new AggregatorInstances(context.Azure, context.Logger); if (!string.IsNullOrEmpty(Location)) { context.Logger.WriteVerbose($"Searching aggregator instances in {Location}..."); - return await ListByLocationAsync(context, instances); + return await ListByLocationAsync(context, instances, cancellationToken); } else if (!string.IsNullOrEmpty(ResourceGroup)) { context.Logger.WriteVerbose($"Searching aggregator instances in {ResourceGroup}..."); - return await ListInResourceGroupAsync(context, instances); + return await ListInResourceGroupAsync(context, instances, cancellationToken); } else { context.Logger.WriteVerbose($"Searching aggregator instances in subscription..."); - return await ListAllAsync(context, instances); + return await ListAllAsync(context, instances, cancellationToken); } } - private async Task ListByLocationAsync(CommandContext context, AggregatorInstances instances) + private async Task ListByLocationAsync(CommandContext context, AggregatorInstances instances, CancellationToken cancellationToken) { - var found = await instances.ListByLocationAsync(Location); + var found = await instances.ListByLocationAsync(Location, cancellationToken); bool any = false; foreach (var dataObject in found) { @@ -54,35 +53,39 @@ private async Task ListByLocationAsync(CommandContext context, AggregatorIn return 0; } - private async Task ListInResourceGroupAsync(CommandContext context, AggregatorInstances instances) + private async Task ListInResourceGroupAsync(CommandContext context, AggregatorInstances instances, CancellationToken cancellationToken) { - var found = await instances.ListInResourceGroupAsync(ResourceGroup); + var found = await instances.ListInResourceGroupAsync(ResourceGroup, cancellationToken); bool any = false; foreach (var dataObject in found) { context.Logger.WriteOutput(dataObject); any = true; } + if (!any) { context.Logger.WriteInfo("No aggregator instances found."); } + return 0; } - private static async Task ListAllAsync(CommandContext context, AggregatorInstances instances) + private static async Task ListAllAsync(CommandContext context, AggregatorInstances instances, CancellationToken cancellationToken) { - var found = await instances.ListAllAsync(); + var found = await instances.ListAllAsync(cancellationToken); bool any = false; foreach (var dataObject in found) { context.Logger.WriteOutput(dataObject); any = true; } + if (!any) { context.Logger.WriteInfo("No aggregator instances found."); } + return 0; } } diff --git a/src/aggregator-cli/Instances/StreamLogsCommand.cs b/src/aggregator-cli/Instances/StreamLogsCommand.cs index ccc11c50..21e49a0b 100644 --- a/src/aggregator-cli/Instances/StreamLogsCommand.cs +++ b/src/aggregator-cli/Instances/StreamLogsCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -15,14 +16,14 @@ class StreamLogsCommand : CommandBase [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] public string Instance { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() - .Build(); + .BuildAsync(cancellationToken); var instance = new InstanceName(Instance, ResourceGroup); var instances = new AggregatorInstances(context.Azure, context.Logger); - bool ok = await instances.StreamLogsAsync(instance); + bool ok = await instances.StreamLogsAsync(instance, cancellationToken); return ok ? 0 : 1; } } diff --git a/src/aggregator-cli/Instances/UninstallInstanceCommand.cs b/src/aggregator-cli/Instances/UninstallInstanceCommand.cs index 6177bf08..ac03f4a2 100644 --- a/src/aggregator-cli/Instances/UninstallInstanceCommand.cs +++ b/src/aggregator-cli/Instances/UninstallInstanceCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -21,24 +22,23 @@ class UninstallInstanceCommand : CommandBase [Option('m', "dont-remove-mappings", Required = false, HelpText = "Do not remove mappings from Azure DevOps (default is to remove them).")] public bool Mappings { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() .WithDevOpsLogon() - .Build(); + .BuildAsync(cancellationToken); var instance = new InstanceName(Name, ResourceGroup); - bool ok; if (!Mappings) { var mappings = new AggregatorMappings(context.Devops, context.Azure, context.Logger); - ok = await mappings.RemoveInstanceAsync(instance); + _ = await mappings.RemoveInstanceAsync(instance); } var instances = new AggregatorInstances(context.Azure, context.Logger); - ok = await instances.Remove(instance, Location); + var ok = await instances.RemoveAsync(instance, Location); return ok ? 0 : 1; } } diff --git a/src/aggregator-cli/Kudu/KuduApi.cs b/src/aggregator-cli/Kudu/KuduApi.cs index 3538dd7f..92d88f00 100644 --- a/src/aggregator-cli/Kudu/KuduApi.cs +++ b/src/aggregator-cli/Kudu/KuduApi.cs @@ -1,12 +1,12 @@ using Microsoft.Azure.Management.AppService.Fluent; using Microsoft.Azure.Management.Fluent; using System; -using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -25,24 +25,25 @@ internal KuduApi(InstanceName instance, IAzure azure, ILogger logger) } string lastPublishCredentialsInstance = string.Empty; - (string username, string password) lastPublishCredentials = default; - private async Task<(string username, string password)> GetPublishCredentials() + (string username, string password) lastPublishCredentials; + private async Task<(string username, string password)> GetPublishCredentials(CancellationToken cancellationToken) { // implements a trivial caching, adequate for command line use if (lastPublishCredentialsInstance != instance.PlainName) { string rg = instance.ResourceGroupName; string fn = instance.FunctionAppName; - IFunctionApp webFunctionApp = null; + IFunctionApp webFunctionApp; try { - webFunctionApp = await azure.AppServices.FunctionApps.GetByResourceGroupAsync(rg, fn); + webFunctionApp = await azure.AppServices.FunctionApps.GetByResourceGroupAsync(rg, fn, cancellationToken); } catch (Exception) { logger.WriteError($"Instance {instance.PlainName} not found."); throw; } + var ftpUsername = webFunctionApp.GetPublishingProfile().FtpUsername; var username = ftpUsername.Split('\\').ToList()[1]; var password = webFunctionApp.GetPublishingProfile().FtpPassword; @@ -50,59 +51,60 @@ internal KuduApi(InstanceName instance, IAzure azure, ILogger logger) lastPublishCredentials = (username, password); lastPublishCredentialsInstance = instance.PlainName; } + return lastPublishCredentials; } - private async Task GetAuthenticationHeader() + private async Task GetAuthenticationHeader(CancellationToken cancellationToken) { - (string username, string password) = await GetPublishCredentials(); + var (username, password) = await GetPublishCredentials(cancellationToken); var base64Auth = Convert.ToBase64String(Encoding.Default.GetBytes($"{username}:{password}")); return new AuthenticationHeaderValue("Basic", base64Auth); } - internal async Task GetAzureFunctionJWTAsync() + internal async Task GetAzureFunctionJWTAsync(CancellationToken cancellationToken) { var kuduUrl = $"{instance.KuduUrl}/api"; string JWT; using (var client = new HttpClient()) { client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("aggregator", "3.0")); - client.DefaultRequestHeaders.Authorization = await GetAuthenticationHeader(); + client.DefaultRequestHeaders.Authorization = await GetAuthenticationHeader(cancellationToken); - var result = await client.GetAsync($"{kuduUrl}/functions/admin/token"); + var result = await client.GetAsync($"{kuduUrl}/functions/admin/token", cancellationToken); JWT = await result.Content.ReadAsStringAsync(); //get JWT for call function key JWT = JWT.Trim('"'); } return JWT; } - internal async Task GetRequestAsync(HttpMethod method, string restApi) + internal async Task GetRequestAsync(HttpMethod method, string restApi, CancellationToken cancellationToken) { var kuduUrl = new Uri(instance.KuduUrl); var request = new HttpRequestMessage(method, $"{kuduUrl}{restApi}"); request.Headers.UserAgent.Add(new ProductInfoHeaderValue("aggregator", "3.0")); - request.Headers.Authorization = await GetAuthenticationHeader(); + request.Headers.Authorization = await GetAuthenticationHeader(cancellationToken); return request; } - internal async Task StreamLogsAsync(TextWriter output) + internal async Task StreamLogsAsync(TextWriter output, CancellationToken cancellationToken) { // see https://github.com/projectkudu/kudu/wiki/Diagnostic-Log-Stream using (var client = new HttpClient()) - using (var request = await this.GetRequestAsync(HttpMethod.Get, $"api/logstream/application")) + using (var request = await GetRequestAsync(HttpMethod.Get, $"api/logstream/application", cancellationToken)) { logger.WriteInfo($"Connected to {instance.PlainName} logs"); - using (var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)) + using (var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)) { logger.WriteVerbose($"Streaming {instance.PlainName} logs..."); var stream = await response.Content.ReadAsStreamAsync(); using (var reader = new StreamReader(stream)) { - while (!reader.EndOfStream && stream != null) + while (!reader.EndOfStream) { //We are ready to read the stream - var line = reader.ReadLine(); + var line = await reader.ReadLineAsync(); await output.WriteLineAsync(line); } diff --git a/src/aggregator-cli/Logon/AzureLogon.cs b/src/aggregator-cli/Logon/AzureLogon.cs index 238246ad..68a939d9 100644 --- a/src/aggregator-cli/Logon/AzureLogon.cs +++ b/src/aggregator-cli/Logon/AzureLogon.cs @@ -32,9 +32,7 @@ public static (AzureLogon connection, LogonResult reason) Load() return (result.connection, result.reason); } -#pragma warning disable CS1998 - public async Task LogonAsync() -#pragma warning restore CS1998 + public IAzure Logon() { try { diff --git a/src/aggregator-cli/Logon/DevOpsLogon.cs b/src/aggregator-cli/Logon/DevOpsLogon.cs index 035d7706..7cb7eed0 100644 --- a/src/aggregator-cli/Logon/DevOpsLogon.cs +++ b/src/aggregator-cli/Logon/DevOpsLogon.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Net; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -27,7 +28,7 @@ public static (DevOpsLogon connection, LogonResult reason) Load() return (result.connection, result.reason); } - public async Task LogonAsync() + public async Task LogonAsync(CancellationToken cancellationToken) { var clientCredentials = default(VssCredentials); switch (Mode) @@ -42,7 +43,7 @@ public async Task LogonAsync() throw new ArgumentOutOfRangeException(nameof(Mode)); } var connection = new VssConnection(new Uri(Url), clientCredentials); - await connection.ConnectAsync(); + await connection.ConnectAsync(cancellationToken); return connection; } } diff --git a/src/aggregator-cli/Logon/LogonAzureCommand.cs b/src/aggregator-cli/Logon/LogonAzureCommand.cs index 3284b4aa..01c86bf9 100644 --- a/src/aggregator-cli/Logon/LogonAzureCommand.cs +++ b/src/aggregator-cli/Logon/LogonAzureCommand.cs @@ -1,6 +1,7 @@ using CommandLine; using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -17,9 +18,9 @@ class LogonAzureCommand : CommandBase [Option('t', "tenant", Required = true, HelpText = "Tenant Id.")] public string TenantId { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { - var context = await Context.Build(); + var context = await Context.BuildAsync(cancellationToken); var data = new AzureLogon() { @@ -31,7 +32,7 @@ internal override async Task RunAsync() string path = data.Save(); // now check for validity context.Logger.WriteInfo("Connecting to Azure..."); - var azure = await data.LogonAsync(); + var azure = data.Logon(); if (azure == null) { context.Logger.WriteError("Invalid azure credentials"); diff --git a/src/aggregator-cli/Logon/LogonDevOpsCommand.cs b/src/aggregator-cli/Logon/LogonDevOpsCommand.cs index 4705ea9c..3737fbe6 100644 --- a/src/aggregator-cli/Logon/LogonDevOpsCommand.cs +++ b/src/aggregator-cli/Logon/LogonDevOpsCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -19,10 +20,9 @@ class LogonDevOpsCommand : CommandBase [Option('t', "token", SetName = "PAT", HelpText = "Azure DevOps Personal Authentication Token.")] public string Token { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { - var context = await Context.Build(); - + var context = await Context.BuildAsync(cancellationToken); var data = new DevOpsLogon() { @@ -33,12 +33,13 @@ internal override async Task RunAsync() string path = data.Save(); // now check for validity context.Logger.WriteInfo($"Connecting to Azure DevOps using {Mode} credential..."); - var devops = await data.LogonAsync(); + var devops = await data.LogonAsync(cancellationToken); if (devops == null) { context.Logger.WriteError("Invalid Azure DevOps credentials"); return 2; } + return 0; } } diff --git a/src/aggregator-cli/Mappings/AggregatorMappings.cs b/src/aggregator-cli/Mappings/AggregatorMappings.cs index 9254bc9a..d566d3d2 100644 --- a/src/aggregator-cli/Mappings/AggregatorMappings.cs +++ b/src/aggregator-cli/Mappings/AggregatorMappings.cs @@ -6,7 +6,7 @@ using Microsoft.VisualStudio.Services.ServiceHooks.WebApi; using Microsoft.TeamFoundation.Core.WebApi; using Microsoft.Azure.Management.Fluent; -using System.Text.RegularExpressions; +using System.Threading; using Microsoft.VisualStudio.Services.FormInput; namespace aggregator.cli @@ -63,7 +63,7 @@ internal async Task> ListAsync(InstanceName insta return result; } - internal async Task Add(string projectName, string @event, InstanceName instance, string ruleName) + internal async Task AddAsync(string projectName, string @event, InstanceName instance, string ruleName, CancellationToken cancellationToken) { logger.WriteVerbose($"Reading Azure DevOps project data..."); var projectClient = devops.GetClient(); @@ -72,7 +72,7 @@ internal async Task Add(string projectName, string @event, InstanceName in var rules = new AggregatorRules(azure, logger); logger.WriteVerbose($"Retrieving {ruleName} Function Key..."); - (string ruleUrl, string ruleKey) = await rules.GetInvocationUrlAndKey(instance, ruleName); + (string ruleUrl, string ruleKey) = await rules.GetInvocationUrlAndKey(instance, ruleName, cancellationToken); logger.WriteInfo($"{ruleName} Function Key retrieved."); var serviceHooksClient = devops.GetClient(); @@ -108,6 +108,8 @@ internal async Task Add(string projectName, string @event, InstanceName in } } }; + + cancellationToken.ThrowIfCancellationRequested(); var queryResult = await serviceHooksClient.QuerySubscriptionsAsync(query); if (queryResult.Results.Any()) { @@ -179,6 +181,7 @@ internal async Task RemoveRuleEventAsync(string @event, InstanceName insta { ruleSubs = ruleSubs.Where(s => s.EventType == @event); } + if (projectName != "*") { logger.WriteVerbose($"Reading Azure DevOps project data..."); @@ -188,12 +191,14 @@ internal async Task RemoveRuleEventAsync(string @event, InstanceName insta ruleSubs = ruleSubs.Where(s => s.PublisherInputs["projectId"] == project.Id.ToString()); } + if (rule != "*") { ruleSubs = ruleSubs .Where(s => s.ConsumerInputs["url"].ToString().StartsWith( AggregatorRules.GetInvocationUrl(instance, rule))); } + foreach (var ruleSub in ruleSubs) { logger.WriteVerbose($"Deleting subscription {ruleSub.EventDescription} {ruleSub.EventType}..."); diff --git a/src/aggregator-cli/Mappings/ListMappingsCommand.cs b/src/aggregator-cli/Mappings/ListMappingsCommand.cs index 582d8e72..eb85361d 100644 --- a/src/aggregator-cli/Mappings/ListMappingsCommand.cs +++ b/src/aggregator-cli/Mappings/ListMappingsCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -19,15 +20,16 @@ class ListMappingsCommand : CommandBase [Option('p', "project", Required = false, Default = "", HelpText = "Azure DevOps project name.")] public string Project { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithDevOpsLogon() - .Build(); + .BuildAsync(cancellationToken); var instance = string.IsNullOrEmpty(Instance) ? null : new InstanceName(Instance, ResourceGroup); // HACK we pass null as the next calls do not use the Azure connection var mappings = new AggregatorMappings(context.Devops, null, context.Logger); bool any = false; + cancellationToken.ThrowIfCancellationRequested(); foreach (var item in await mappings.ListAsync(instance, Project)) { context.Logger.WriteOutput(item); diff --git a/src/aggregator-cli/Mappings/MapRuleCommand.cs b/src/aggregator-cli/Mappings/MapRuleCommand.cs index 148b5321..c680b38b 100644 --- a/src/aggregator-cli/Mappings/MapRuleCommand.cs +++ b/src/aggregator-cli/Mappings/MapRuleCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -24,12 +25,12 @@ class MapRuleCommand : CommandBase [Option('r', "rule", Required = true, HelpText = "Aggregator rule name.")] public string Rule { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() .WithDevOpsLogon() - .Build(); + .BuildAsync(cancellationToken); var mappings = new AggregatorMappings(context.Devops, context.Azure, context.Logger); bool ok = DevOpsEvents.IsValidEvent(Event); if (!ok) @@ -37,8 +38,9 @@ internal override async Task RunAsync() context.Logger.WriteError($"Invalid event type."); return 2; } + var instance = new InstanceName(Instance, ResourceGroup); - var id = await mappings.Add(Project, Event, instance, Rule); + var id = await mappings.AddAsync(Project, Event, instance, Rule, cancellationToken); return id.Equals(Guid.Empty) ? 1 : 0; } } diff --git a/src/aggregator-cli/Mappings/UnmapRuleCommand.cs b/src/aggregator-cli/Mappings/UnmapRuleCommand.cs index 019fe187..32b25f64 100644 --- a/src/aggregator-cli/Mappings/UnmapRuleCommand.cs +++ b/src/aggregator-cli/Mappings/UnmapRuleCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -24,12 +25,12 @@ class UnmapRuleCommand : CommandBase [Option('r', "rule", Required = true, HelpText = "Aggregator rule name.")] public string Rule { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() .WithDevOpsLogon() - .Build(); + .BuildAsync(cancellationToken); var instance = new InstanceName(Instance, ResourceGroup); var mappings = new AggregatorMappings(context.Devops, context.Azure, context.Logger); bool ok = await mappings.RemoveRuleEventAsync(Event, instance, Project, Rule); diff --git a/src/aggregator-cli/Program.cs b/src/aggregator-cli/Program.cs index 71d5727f..0b17b230 100644 --- a/src/aggregator-cli/Program.cs +++ b/src/aggregator-cli/Program.cs @@ -1,6 +1,7 @@ using CommandLine; using CommandLine.Text; using System; +using System.Threading; namespace aggregator.cli { @@ -34,54 +35,67 @@ public class Program public static int Main(string[] args) { var save = Console.ForegroundColor; - Console.CancelKeyPress += delegate { - // call methods to clean up - Console.ForegroundColor = save; - }; - - var parser = new Parser(settings => + using (var cancellationTokenSource = new CancellationTokenSource()) { - settings.CaseSensitive = false; - // fails see https://github.com/commandlineparser/commandline/issues/198 - settings.CaseInsensitiveEnumValues = true; - }); - var types = new Type[] { - typeof(TestCommand), - typeof(LogonAzureCommand), typeof(LogonDevOpsCommand), - typeof(ListInstancesCommand), typeof(InstallInstanceCommand), typeof(UninstallInstanceCommand), - typeof(ConfigureInstanceCommand), typeof(StreamLogsCommand), - typeof(ListRulesCommand), typeof(AddRuleCommand), typeof(RemoveRuleCommand), - typeof(ConfigureRuleCommand), typeof(UpdateRuleCommand), typeof(InvokeRuleCommand), - typeof(ListMappingsCommand), typeof(MapRuleCommand), typeof(UnmapRuleCommand) - }; - var parserResult = parser.ParseArguments(args, types); - int rc = -1; - parserResult - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithParsed(cmd => rc = cmd.Run()) - .WithNotParsed(errs => + void cancelEventHandler(object sender, ConsoleCancelEventArgs e) { - var helpText = HelpText.AutoBuild(parserResult); - Console.Error.Write(helpText); - rc = 1; + // call methods to clean up + Console.ForegroundColor = save; + if (!cancellationTokenSource.IsCancellationRequested) + { + cancellationTokenSource.Cancel(); + } + } + + Console.CancelKeyPress += cancelEventHandler; + + var parser = new Parser(settings => + { + settings.CaseSensitive = false; + // fails see https://github.com/commandlineparser/commandline/issues/198 + settings.CaseInsensitiveEnumValues = true; }); - Console.ForegroundColor = save; - return rc; + var types = new Type[] + { + typeof(TestCommand), + typeof(LogonAzureCommand), typeof(LogonDevOpsCommand), + typeof(ListInstancesCommand), typeof(InstallInstanceCommand), typeof(UninstallInstanceCommand), + typeof(ConfigureInstanceCommand), typeof(StreamLogsCommand), + typeof(ListRulesCommand), typeof(AddRuleCommand), typeof(RemoveRuleCommand), + typeof(ConfigureRuleCommand), typeof(UpdateRuleCommand), typeof(InvokeRuleCommand), + typeof(ListMappingsCommand), typeof(MapRuleCommand), typeof(UnmapRuleCommand) + }; + var parserResult = parser.ParseArguments(args, types); + int rc = -1; + var cancellationToken = cancellationTokenSource.Token; + parserResult + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithParsed(cmd => rc = cmd.Run(cancellationToken)) + .WithNotParsed(errs => + { + var helpText = HelpText.AutoBuild(parserResult); + Console.Error.Write(helpText); + rc = 1; + }); + Console.ForegroundColor = save; + Console.CancelKeyPress -= cancelEventHandler; + return rc; + } } } } diff --git a/src/aggregator-cli/Rules/AddRuleCommand.cs b/src/aggregator-cli/Rules/AddRuleCommand.cs index ab948258..bd6c056c 100644 --- a/src/aggregator-cli/Rules/AddRuleCommand.cs +++ b/src/aggregator-cli/Rules/AddRuleCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -21,14 +22,14 @@ class AddRuleCommand : CommandBase [Option('f', "file", Required = true, HelpText = "Aggregator rule code.")] public string File { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() - .Build(); + .BuildAsync(cancellationToken); var instance = new InstanceName(Instance, ResourceGroup); var rules = new AggregatorRules(context.Azure, context.Logger); - bool ok = await rules.AddAsync(instance, Name, File); + bool ok = await rules.AddAsync(instance, Name, File, cancellationToken); return ok ? 0 : 1; } } diff --git a/src/aggregator-cli/Rules/AggregatorRules.cs b/src/aggregator-cli/Rules/AggregatorRules.cs index 7a84de07..8831f466 100644 --- a/src/aggregator-cli/Rules/AggregatorRules.cs +++ b/src/aggregator-cli/Rules/AggregatorRules.cs @@ -32,15 +32,13 @@ public AggregatorRules(IAzure azure, ILogger logger) this.logger = logger; } - - internal async Task> ListAsync(InstanceName instance) + internal async Task> ListAsync(InstanceName instance, CancellationToken cancellationToken) { - var instances = new AggregatorInstances(azure, logger); var kudu = new KuduApi(instance, azure, logger); logger.WriteInfo($"Retrieving Functions in {instance.PlainName}..."); using (var client = new HttpClient()) - using (var request = await kudu.GetRequestAsync(HttpMethod.Get, $"api/functions")) - using (var response = await client.SendAsync(request)) + using (var request = await kudu.GetRequestAsync(HttpMethod.Get, $"api/functions", cancellationToken)) + using (var response = await client.SendAsync(request, cancellationToken)) { var stream = await response.Content.ReadAsStreamAsync(); @@ -67,16 +65,16 @@ internal static string GetInvocationUrl(InstanceName instance, string rule) return $"{instance.FunctionAppUrl}/api/{rule}"; } - internal async Task<(string url, string key)> GetInvocationUrlAndKey(InstanceName instance, string rule) + internal async Task<(string url, string key)> GetInvocationUrlAndKey(InstanceName instance, string rule, CancellationToken cancellationToken) { var instances = new AggregatorInstances(azure, logger); var kudu = new KuduApi(instance, azure, logger); // see https://github.com/projectkudu/kudu/wiki/Functions-API using (var client = new HttpClient()) - using (var request = await kudu.GetRequestAsync(HttpMethod.Post, $"api/functions/{rule}/listsecrets")) + using (var request = await kudu.GetRequestAsync(HttpMethod.Post, $"api/functions/{rule}/listsecrets", cancellationToken)) { - using (var response = await client.SendAsync(request)) + using (var response = await client.SendAsync(request, cancellationToken)) { if (response.IsSuccessStatusCode) { @@ -101,20 +99,19 @@ internal static string GetInvocationUrl(InstanceName instance, string rule) } } - internal async Task AddAsync(InstanceName instance, string name, string filePath) + internal async Task AddAsync(InstanceName instance, string name, string filePath, CancellationToken cancellationToken) { - var kudu = new KuduApi(instance, azure, logger); - logger.WriteVerbose($"Layout rule files"); string baseDirPath = LayoutRuleFiles(name, filePath); logger.WriteInfo($"Packaging {filePath} into rule {name} complete."); logger.WriteVerbose($"Uploading rule files to {instance.PlainName}"); - bool ok = await UploadRuleFiles(instance, name, baseDirPath); + bool ok = await UploadRuleFiles(instance, name, baseDirPath, cancellationToken); if (ok) { logger.WriteInfo($"All {name} files uploaded to {instance.PlainName}."); } + CleanupRuleFiles(baseDirPath); logger.WriteInfo($"Cleaned local working directory."); return ok; @@ -158,7 +155,7 @@ void CleanupRuleFiles(string baseDirPath) Directory.Delete(baseDirPath, true); } - private async Task UploadRuleFiles(InstanceName instance, string name, string baseDirPath) + private async Task UploadRuleFiles(InstanceName instance, string name, string baseDirPath, CancellationToken cancellationToken) { /* PUT /api/vfs/{path} @@ -172,13 +169,12 @@ Puts a file at path. var kudu = new KuduApi(instance, azure, logger); string relativeUrl = $"api/vfs/site/wwwroot/{name}/"; - var instances = new AggregatorInstances(azure, logger); using (var client = new HttpClient()) { bool exists = false; // check if function already exists - using (var request = await kudu.GetRequestAsync(HttpMethod.Head, relativeUrl)) + using (var request = await kudu.GetRequestAsync(HttpMethod.Head, relativeUrl, cancellationToken)) { logger.WriteVerbose($"Checking if function {name} already exists in {instance.PlainName}..."); using (var response = await client.SendAsync(request)) @@ -190,9 +186,9 @@ Puts a file at path. if (!exists) { logger.WriteVerbose($"Creating function {name} in {instance.PlainName}..."); - using (var request = await kudu.GetRequestAsync(HttpMethod.Put, relativeUrl)) + using (var request = await kudu.GetRequestAsync(HttpMethod.Put, relativeUrl, cancellationToken)) { - using (var response = await client.SendAsync(request)) + using (var response = await client.SendAsync(request, cancellationToken)) { bool ok = response.IsSuccessStatusCode; if (!ok) @@ -210,12 +206,12 @@ Puts a file at path. { logger.WriteVerbose($"Uploading {Path.GetFileName(file)} to {instance.PlainName}..."); string fileUrl = $"{relativeUrl}{Path.GetFileName(file)}"; - using (var request = await kudu.GetRequestAsync(HttpMethod.Put, fileUrl)) + using (var request = await kudu.GetRequestAsync(HttpMethod.Put, fileUrl, cancellationToken)) { //HACK -> request.Headers.IfMatch.Add(new EntityTagHeaderValue("*", false)); <- won't work request.Headers.Add("If-Match", "*"); request.Content = new StringContent(File.ReadAllText(file)); - using (var response = await client.SendAsync(request)) + using (var response = await client.SendAsync(request, cancellationToken)) { bool ok = response.IsSuccessStatusCode; if (!ok) @@ -231,33 +227,34 @@ Puts a file at path. return true; } - internal async Task RemoveAsync(InstanceName instance, string name) + internal async Task RemoveAsync(InstanceName instance, string name, CancellationToken cancellationToken) { var kudu = new KuduApi(instance, azure, logger); - var instances = new AggregatorInstances(azure, logger); // undocumented but works, see https://github.com/projectkudu/kudu/wiki/Functions-API logger.WriteInfo($"Removing Function {name} in {instance.PlainName}..."); using (var client = new HttpClient()) - using (var request = await kudu.GetRequestAsync(HttpMethod.Delete, $"api/functions/{name}")) - using (var response = await client.SendAsync(request)) + using (var request = await kudu.GetRequestAsync(HttpMethod.Delete, $"api/functions/{name}", cancellationToken)) + using (var response = await client.SendAsync(request, cancellationToken)) { bool ok = response.IsSuccessStatusCode; if (!ok) { logger.WriteError($"Failed removing Function {name} from {instance.PlainName} with {response.ReasonPhrase}"); } + return ok; } } - internal async Task EnableAsync(InstanceName instance, string name, bool disable) + internal async Task EnableAsync(InstanceName instance, string name, bool disable, CancellationToken cancellationToken) { var webFunctionApp = await azure .AppServices .WebApps .GetByResourceGroupAsync( instance.ResourceGroupName, - instance.FunctionAppName); + instance.FunctionAppName, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); webFunctionApp .Update() .WithAppSetting($"AzureWebJobs.{name}.Disabled", disable.ToString().ToLower()) @@ -266,19 +263,19 @@ internal async Task EnableAsync(InstanceName instance, string name, bool d return true; } - internal async Task UpdateAsync(InstanceName instance, string name, string filePath, string requiredVersion) + internal async Task UpdateAsync(InstanceName instance, string name, string filePath, string requiredVersion, CancellationToken cancellationToken) { // check runtime package var package = new FunctionRuntimePackage(logger); - bool ok = await package.UpdateVersion(requiredVersion, instance, azure); + bool ok = await package.UpdateVersionAsync(requiredVersion, instance, azure, cancellationToken); if (ok) { - ok = await AddAsync(instance, name, filePath); + ok = await AddAsync(instance, name, filePath, cancellationToken); } return ok; } - internal async Task InvokeLocalAsync(string projectName, string @event, int workItemId, string ruleFilePath, bool dryRun, SaveMode saveMode) + internal async Task InvokeLocalAsync(string projectName, string @event, int workItemId, string ruleFilePath, bool dryRun, SaveMode saveMode, CancellationToken cancellationToken) { if (!File.Exists(ruleFilePath)) { @@ -303,7 +300,7 @@ internal async Task InvokeLocalAsync(string projectName, string @event, in string collectionUrl = devopsLogonData.Url; using (var devops = new VssConnection(new Uri(collectionUrl), clientCredentials)) { - await devops.ConnectAsync(); + await devops.ConnectAsync(cancellationToken); logger.WriteInfo($"Connected to Azure DevOps"); Guid teamProjectId; @@ -320,12 +317,12 @@ internal async Task InvokeLocalAsync(string projectName, string @event, in using (var witClient = devops.GetClient()) { logger.WriteVerbose($"Rule code found at {ruleFilePath}"); - string[] ruleCode = File.ReadAllLines(ruleFilePath); + var ruleCode = await File.ReadAllLinesAsync(ruleFilePath, cancellationToken); var engineLogger = new EngineWrapperLogger(logger); var engine = new Engine.RuleEngine(engineLogger, ruleCode, saveMode, dryRun: dryRun); - string result = await engine.ExecuteAsync(collectionUrl, teamProjectId, teamProjectName, devopsLogonData.Token, workItemId, witClient); + string result = await engine.ExecuteAsync(collectionUrl, teamProjectId, teamProjectName, devopsLogonData.Token, workItemId, witClient, cancellationToken); logger.WriteInfo($"Rule returned '{result}'"); return true; @@ -333,13 +330,11 @@ internal async Task InvokeLocalAsync(string projectName, string @event, in } } - internal async Task InvokeRemoteAsync(string account, string project, string @event, int workItemId, InstanceName instance, string ruleName, bool dryRun, SaveMode saveMode) + internal async Task InvokeRemoteAsync(string account, string project, string @event, int workItemId, InstanceName instance, string ruleName, bool dryRun, SaveMode saveMode, CancellationToken cancellationToken) { - var kudu = new KuduApi(instance, azure, logger); - // build the request ... logger.WriteVerbose($"Retrieving {ruleName} Function Key..."); - (string ruleUrl, string ruleKey) = await this.GetInvocationUrlAndKey(instance, ruleName); + var (ruleUrl, ruleKey) = await GetInvocationUrlAndKey(instance, ruleName, cancellationToken); logger.WriteInfo($"{ruleName} Function Key retrieved."); ruleUrl = InvokeOptions.AppendToUrl(ruleUrl, dryRun, saveMode); @@ -382,7 +377,7 @@ internal async Task InvokeRemoteAsync(string account, string project, stri request.Headers.Add("x-functions-key", ruleKey); request.Content = new StringContent(body, Encoding.UTF8, "application/json"); - using (var response = await client.SendAsync(request)) + using (var response = await client.SendAsync(request, cancellationToken)) { if (response.IsSuccessStatusCode) { diff --git a/src/aggregator-cli/Rules/ConfigureRuleCommand.cs b/src/aggregator-cli/Rules/ConfigureRuleCommand.cs index 40fb620a..51526555 100644 --- a/src/aggregator-cli/Rules/ConfigureRuleCommand.cs +++ b/src/aggregator-cli/Rules/ConfigureRuleCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -23,17 +24,17 @@ class ConfigureRuleCommand : CommandBase [Option('e', "enable", SetName = "enable", HelpText = "Enable the rule.")] public bool Enable { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() - .Build(); + .BuildAsync(cancellationToken); var instance = new InstanceName(Instance, ResourceGroup); var rules = new AggregatorRules(context.Azure, context.Logger); bool ok = false; if (Disable || Enable) { - ok = await rules.EnableAsync(instance, Name, Disable); + ok = await rules.EnableAsync(instance, Name, Disable, cancellationToken); } return ok ? 0 : 1; } diff --git a/src/aggregator-cli/Rules/InvokeRuleCommand.cs b/src/aggregator-cli/Rules/InvokeRuleCommand.cs index 57b012eb..a7dc53de 100644 --- a/src/aggregator-cli/Rules/InvokeRuleCommand.cs +++ b/src/aggregator-cli/Rules/InvokeRuleCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -43,23 +44,23 @@ class InvokeRuleCommand : CommandBase public string Name { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() .WithDevOpsLogon() - .Build(); + .BuildAsync(cancellationToken); var rules = new AggregatorRules(context.Azure, context.Logger); if (Local) { - bool ok = await rules.InvokeLocalAsync(Project, Event, WorkItemId, Source, DryRun, SaveMode); + bool ok = await rules.InvokeLocalAsync(Project, Event, WorkItemId, Source, DryRun, SaveMode, cancellationToken); return ok ? 0 : 1; } else { var instance = new InstanceName(Instance, ResourceGroup); context.Logger.WriteWarning("Untested feature!"); - bool ok = await rules.InvokeRemoteAsync(Account, Project, Event, WorkItemId, instance, Name, DryRun, SaveMode); + bool ok = await rules.InvokeRemoteAsync(Account, Project, Event, WorkItemId, instance, Name, DryRun, SaveMode, cancellationToken); return ok ? 0 : 1; } } diff --git a/src/aggregator-cli/Rules/ListRulesCommand.cs b/src/aggregator-cli/Rules/ListRulesCommand.cs index 8c567b54..1e4ecc57 100644 --- a/src/aggregator-cli/Rules/ListRulesCommand.cs +++ b/src/aggregator-cli/Rules/ListRulesCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -15,23 +16,26 @@ class ListRulesCommand : CommandBase [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] public string Instance { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() - .Build(); + .BuildAsync(cancellationToken); var instance = new InstanceName(Instance, ResourceGroup); var rules = new AggregatorRules(context.Azure, context.Logger); bool any = false; - foreach (var item in await rules.ListAsync(instance)) + foreach (var item in await rules.ListAsync(instance, cancellationToken)) { + cancellationToken.ThrowIfCancellationRequested(); context.Logger.WriteOutput(new RuleOutputData(instance, item)); any = true; } + if (!any) { context.Logger.WriteInfo($"No rules found in aggregator instance {instance.PlainName}."); } + return 0; } } diff --git a/src/aggregator-cli/Rules/RemoveRuleCommand.cs b/src/aggregator-cli/Rules/RemoveRuleCommand.cs index 4c307731..1e9e4fee 100644 --- a/src/aggregator-cli/Rules/RemoveRuleCommand.cs +++ b/src/aggregator-cli/Rules/RemoveRuleCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -18,19 +19,19 @@ class RemoveRuleCommand : CommandBase [Option('n', "name", Required = true, HelpText = "Aggregator rule name.")] public string Name { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() .WithDevOpsLogon() - .Build(); + .BuildAsync(cancellationToken); var instance = new InstanceName(Instance, ResourceGroup); var mappings = new AggregatorMappings(context.Devops, context.Azure, context.Logger); bool ok = await mappings.RemoveRuleAsync(instance, Name); var rules = new AggregatorRules(context.Azure, context.Logger); //rules.Progress += Instances_Progress; - ok = ok && await rules.RemoveAsync(instance, Name); + ok = ok && await rules.RemoveAsync(instance, Name, cancellationToken); return ok ? 0 : 1; } } diff --git a/src/aggregator-cli/Rules/UpdateRuleCommand.cs b/src/aggregator-cli/Rules/UpdateRuleCommand.cs index 4618f947..e9b85e7e 100644 --- a/src/aggregator-cli/Rules/UpdateRuleCommand.cs +++ b/src/aggregator-cli/Rules/UpdateRuleCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -24,14 +25,14 @@ class UpdateRuleCommand : CommandBase [Option("requiredVersion", Required = false, HelpText = "Version of Aggregator Runtime required.")] public string RequiredVersion { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() - .Build(); + .BuildAsync(cancellationToken); var instance = new InstanceName(Instance, ResourceGroup); var rules = new AggregatorRules(context.Azure, context.Logger); - bool ok = await rules.UpdateAsync(instance, Name, File, RequiredVersion); + bool ok = await rules.UpdateAsync(instance, Name, File, RequiredVersion, cancellationToken); return ok ? 0 : 1; } } diff --git a/src/aggregator-cli/Rules/run.csx b/src/aggregator-cli/Rules/run.csx index cbd770d7..afda0641 100644 --- a/src/aggregator-cli/Rules/run.csx +++ b/src/aggregator-cli/Rules/run.csx @@ -1,10 +1,13 @@ #r "../bin/aggregator-function.dll" #r "../bin/aggregator-shared.dll" +using System.Threading; + using aggregator; -public static async Task Run(HttpRequestMessage req, ILogger logger, ExecutionContext context) +public static async Task Run(HttpRequestMessage req, ILogger logger, Microsoft.Azure.WebJobs.ExecutionContext context, CancellationToken cancellationToken) { var handler = new AzureFunctionHandler(logger, context); - return await handler.Run(req); + var result = await handler.RunAsync(req, cancellationToken); + return result; } diff --git a/src/aggregator-cli/TestCommand.cs b/src/aggregator-cli/TestCommand.cs index 20d35905..c782d284 100644 --- a/src/aggregator-cli/TestCommand.cs +++ b/src/aggregator-cli/TestCommand.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.cli @@ -15,14 +16,14 @@ class TestCommand : CommandBase [Option('i', "instance", Required = true, HelpText = "Aggregator instance name.")] public string Instance { get; set; } - internal override async Task RunAsync() + internal override async Task RunAsync(CancellationToken cancellationToken) { var context = await Context .WithAzureLogon() - .Build(); + .BuildAsync(cancellationToken); var instance = new InstanceName(Instance, ResourceGroup); var instances = new AggregatorInstances(context.Azure, context.Logger); - bool ok = await instances.StreamLogsAsync(instance); + bool ok = await instances.StreamLogsAsync(instance, cancellationToken); return ok ? 0 : 1; } } diff --git a/src/aggregator-function/AssemblyInfo.cs b/src/aggregator-function/AssemblyInfo.cs index 1fb40c12..1599489e 100644 --- a/src/aggregator-function/AssemblyInfo.cs +++ b/src/aggregator-function/AssemblyInfo.cs @@ -1,5 +1,4 @@ -using System; -using System.Reflection; +using System.Reflection; [assembly: AssemblyCompany("TFS Aggregator Team")] #if DEBUG diff --git a/src/aggregator-function/AzureFunctionHandler.cs b/src/aggregator-function/AzureFunctionHandler.cs index c504af8d..d370c583 100644 --- a/src/aggregator-function/AzureFunctionHandler.cs +++ b/src/aggregator-function/AzureFunctionHandler.cs @@ -1,5 +1,3 @@ -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.Host; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -7,7 +5,9 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; +using ExecutionContext = Microsoft.Azure.WebJobs.ExecutionContext; namespace aggregator { @@ -16,36 +16,38 @@ namespace aggregator /// public class AzureFunctionHandler { - private readonly Microsoft.Extensions.Logging.ILogger log; - private readonly ExecutionContext context; + private readonly ILogger _log; + private readonly ExecutionContext _context; - public AzureFunctionHandler(Microsoft.Extensions.Logging.ILogger logger, ExecutionContext context) + public AzureFunctionHandler(ILogger logger, ExecutionContext context) { - this.log = logger; - this.context = context; + _log = logger; + _context = context; } - public async Task Run(HttpRequestMessage req) + public async Task RunAsync(HttpRequestMessage req, CancellationToken cancellationToken) { - log.LogDebug($"Context: {context.InvocationId} {context.FunctionName} {context.FunctionDirectory} {context.FunctionAppDirectory}"); + _log.LogDebug($"Context: {_context.InvocationId} {_context.FunctionName} {_context.FunctionDirectory} {_context.FunctionAppDirectory}"); var aggregatorVersion = GetCustomAttribute()?.InformationalVersion; try { - string rule = context.FunctionName; - log.LogInformation($"Aggregator v{aggregatorVersion} executing rule '{rule}'"); + var rule = _context.FunctionName; + _log.LogInformation($"Aggregator v{aggregatorVersion} executing rule '{rule}'"); } catch (Exception ex) { - log.LogWarning($"Failed parsing request headers: {ex.Message}"); + _log.LogWarning($"Failed parsing request headers: {ex.Message}"); } + cancellationToken.ThrowIfCancellationRequested(); + // Get request body - string jsonContent = await req.Content.ReadAsStringAsync(); + var jsonContent = await req.Content.ReadAsStringAsync(); if (string.IsNullOrWhiteSpace(jsonContent)) { - log.LogWarning($"Failed parsing request body: empty"); + _log.LogWarning($"Failed parsing request body: empty"); var resp = new HttpResponseMessage(HttpStatusCode.BadRequest) { @@ -53,6 +55,7 @@ public async Task Run(HttpRequestMessage req) }; return resp; } + dynamic data = JsonConvert.DeserializeObject(jsonContent); string eventType = data.eventType; @@ -70,26 +73,26 @@ public async Task Run(HttpRequestMessage req) { var resp = req.CreateResponse(HttpStatusCode.OK, new { - message = $"Hello from Aggregator v{aggregatorVersion} executing rule '{context.FunctionName}'" + message = $"Hello from Aggregator v{aggregatorVersion} executing rule '{_context.FunctionName}'" }); resp.Headers.Add("X-Aggregator-Version", aggregatorVersion); - resp.Headers.Add("X-Aggregator-Rule", context.FunctionName); + resp.Headers.Add("X-Aggregator-Rule", _context.FunctionName); return resp; } var config = new ConfigurationBuilder() - .SetBasePath(context.FunctionAppDirectory) + .SetBasePath(_context.FunctionAppDirectory) .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true) .AddEnvironmentVariables() .Build(); var configuration = AggregatorConfiguration.Read(config); configuration = InvokeOptions.ExtendFromUrl(configuration, req.RequestUri); - var logger = new ForwarderLogger(log); - var wrapper = new RuleWrapper(configuration, logger, context.FunctionName, context.FunctionDirectory); + var logger = new ForwarderLogger(_log); + var wrapper = new RuleWrapper(configuration, logger, _context.FunctionName, _context.FunctionDirectory); try { - string execResult = await wrapper.Execute(data); + string execResult = await wrapper.ExecuteAsync(data, cancellationToken); if (string.IsNullOrEmpty(execResult)) { @@ -98,7 +101,7 @@ public async Task Run(HttpRequestMessage req) } else { - log.LogInformation($"Returning '{execResult}' from '{context.FunctionName}'"); + _log.LogInformation($"Returning '{execResult}' from '{_context.FunctionName}'"); var resp = new HttpResponseMessage(HttpStatusCode.OK) { @@ -109,7 +112,7 @@ public async Task Run(HttpRequestMessage req) } catch (Exception ex) { - log.LogWarning($"Rule '{context.FunctionName}' failed: {ex.Message}"); + _log.LogWarning($"Rule '{_context.FunctionName}' failed: {ex.Message}"); var resp = new HttpResponseMessage(HttpStatusCode.NotImplemented) { diff --git a/src/aggregator-function/AzureFunctionHandlerExtension.cs b/src/aggregator-function/AzureFunctionHandlerExtension.cs new file mode 100644 index 00000000..81e7d27f --- /dev/null +++ b/src/aggregator-function/AzureFunctionHandlerExtension.cs @@ -0,0 +1,20 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace aggregator +{ + public static class AzureFunctionHandlerExtension + { + /// + /// This method exists for backward compatibility reasons. + /// + /// + /// + /// + public static async Task Run(this AzureFunctionHandler @this, HttpRequestMessage req) + { + return await @this.RunAsync(req, CancellationToken.None); + } + } +} diff --git a/src/aggregator-function/RuleWrapper.cs b/src/aggregator-function/RuleWrapper.cs index d1c45c17..4b957e51 100644 --- a/src/aggregator-function/RuleWrapper.cs +++ b/src/aggregator-function/RuleWrapper.cs @@ -1,14 +1,9 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Reflection; +using System.Threading; using System.Threading.Tasks; -using Microsoft.CodeAnalysis.CSharp.Scripting; -using Microsoft.CodeAnalysis.Scripting; -using Microsoft.Extensions.Configuration; using Microsoft.TeamFoundation.WorkItemTracking.WebApi; -using Microsoft.TeamFoundation.WorkItemTracking.WebApi.Models; using Microsoft.VisualStudio.Services.Common; using Microsoft.VisualStudio.Services.WebApi; @@ -32,7 +27,7 @@ public RuleWrapper(AggregatorConfiguration configuration, IAggregatorLogger logg this.functionDirectory = functionDirectory; } - internal async Task Execute(dynamic data) + internal async Task ExecuteAsync(dynamic data, CancellationToken cancellationToken) { string collectionUrl = data.resourceContainers.collection.baseUrl; string eventType = data.eventType; @@ -49,15 +44,18 @@ internal async Task Execute(dynamic data) if (configuration.DevOpsTokenType == DevOpsTokenType.PAT) { clientCredentials = new VssBasicCredential(configuration.DevOpsTokenType.ToString(), configuration.DevOpsToken); - } else + } + else { logger.WriteError($"Azure DevOps Token type {configuration.DevOpsTokenType} not supported!"); throw new ArgumentOutOfRangeException(nameof(configuration.DevOpsTokenType)); } + cancellationToken.ThrowIfCancellationRequested(); + using (var devops = new VssConnection(new Uri(collectionUrl), clientCredentials)) { - await devops.ConnectAsync(); + await devops.ConnectAsync(cancellationToken); logger.WriteInfo($"Connected to Azure DevOps"); using (var witClient = devops.GetClient()) { @@ -69,13 +67,30 @@ internal async Task Execute(dynamic data) } logger.WriteVerbose($"Rule code found at {ruleFilePath}"); - string[] ruleCode = File.ReadAllLines(ruleFilePath); + string[] ruleCode; + using (var fileStream = File.OpenRead(ruleFilePath)) + { + var reader = new StreamReader(fileStream); + ruleCode = await ReadAllLinesAsync(reader); + } var engine = new Engine.RuleEngine(logger, ruleCode, configuration.SaveMode, configuration.DryRun); - return await engine.ExecuteAsync(collectionUrl, teamProjectId, teamProjectName, configuration.DevOpsToken, workItemId, witClient); + return await engine.ExecuteAsync(collectionUrl, teamProjectId, teamProjectName, configuration.DevOpsToken, workItemId, witClient, cancellationToken); } } } + + private static async Task ReadAllLinesAsync(TextReader streamReader) + { + var lines = new List(); + string line; + while ((line = await streamReader.ReadLineAsync()) != null) + { + lines.Add(line); + } + + return lines.ToArray(); + } } } diff --git a/src/aggregator-ruleng/BatchProxy.cs b/src/aggregator-ruleng/BatchProxy.cs index b2eff9d4..3b1695d7 100644 --- a/src/aggregator-ruleng/BatchProxy.cs +++ b/src/aggregator-ruleng/BatchProxy.cs @@ -1,14 +1,14 @@ using System; -using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Text; +using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; namespace aggregator.Engine { - class BatchProxy + internal class BatchProxy { private readonly bool _commit; private readonly EngineContext _context; @@ -21,10 +21,10 @@ internal BatchProxy(EngineContext context, bool commit) internal string ApiVersion => "api-version=4.1"; - internal async Task Invoke(BatchRequest[] batchRequests) + internal async Task InvokeAsync(BatchRequest[] batchRequests, CancellationToken cancellationToken) { string baseUriString = _context.Client.BaseAddress.AbsoluteUri; - string credentials = Convert.ToBase64String(ASCIIEncoding.ASCII.GetBytes($":{_context.PersonalAccessToken}")); + string credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($":{_context.PersonalAccessToken}")); var converters = new JsonConverter[] { new JsonPatchOperationConverter() }; string requestBody = JsonConvert.SerializeObject(batchRequests, Formatting.Indented, converters); @@ -44,11 +44,11 @@ internal async Task Invoke(BatchRequest[] batchReques // send the request var request = new HttpRequestMessage(method, $"{baseUriString}/_apis/wit/$batch?{ApiVersion}") { Content = batchRequest }; - var response = client.SendAsync(request).Result; + var response = await client.SendAsync(request, cancellationToken); if (response.IsSuccessStatusCode) { - WorkItemBatchPostResponse batchResponse = response.Content.ReadAsAsync().Result; + WorkItemBatchPostResponse batchResponse = await response.Content.ReadAsAsync(cancellationToken); string stringResponse = JsonConvert.SerializeObject(batchResponse, Formatting.Indented); _context.Logger.WriteVerbose($"Workitem(s) batch response:"); @@ -63,6 +63,7 @@ internal async Task Invoke(BatchRequest[] batchReques succeeded = false; } } + if (!succeeded) { throw new ApplicationException($"Save failed."); diff --git a/src/aggregator-ruleng/RuleEngine.cs b/src/aggregator-ruleng/RuleEngine.cs index e56713b2..4d701f45 100644 --- a/src/aggregator-ruleng/RuleEngine.cs +++ b/src/aggregator-ruleng/RuleEngine.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.CSharp.Scripting; using Microsoft.CodeAnalysis.Scripting; @@ -76,9 +77,9 @@ public RuleEngine(IAggregatorLogger logger, string[] ruleCode, SaveMode mode, bo /// State is used by unit tests /// public EngineState State { get; private set; } - public bool DryRun { get; private set; } + public bool DryRun { get; } - public async Task ExecuteAsync(string collectionUrl, Guid projectId, string projectName, string personalAccessToken, int workItemId, WorkItemTrackingHttpClientBase witClient) + public async Task ExecuteAsync(string collectionUrl, Guid projectId, string projectName, string personalAccessToken, int workItemId, WorkItemTrackingHttpClientBase witClient, CancellationToken cancellationToken) { if (State == EngineState.Error) { @@ -97,7 +98,7 @@ public async Task ExecuteAsync(string collectionUrl, Guid projectId, str }; logger.WriteInfo($"Executing Rule..."); - var result = await roslynScript.RunAsync(globals); + var result = await roslynScript.RunAsync(globals, cancellationToken); if (result.Exception != null) { logger.WriteError($"Rule failed with {result.Exception}"); @@ -114,7 +115,7 @@ public async Task ExecuteAsync(string collectionUrl, Guid projectId, str State = EngineState.Success; logger.WriteVerbose($"Post-execution, save any change (mode {saveMode})..."); - var saveRes = await store.SaveChanges(saveMode, !this.DryRun); + var saveRes = await store.SaveChanges(saveMode, !DryRun, cancellationToken); if (saveRes.created + saveRes.updated > 0) { logger.WriteInfo($"Changes saved to Azure DevOps (mode {saveMode}): {saveRes.created} created, {saveRes.updated} updated."); diff --git a/src/aggregator-ruleng/WorkItemStore.cs b/src/aggregator-ruleng/WorkItemStore.cs index bc021170..075757d8 100644 --- a/src/aggregator-ruleng/WorkItemStore.cs +++ b/src/aggregator-ruleng/WorkItemStore.cs @@ -6,6 +6,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Text; +using System.Threading; using System.Threading.Tasks; namespace aggregator.Engine @@ -86,7 +87,7 @@ public WorkItemWrapper NewWorkItem(string workItemType, string projectName = nul return wrapper; } - public async Task<(int created, int updated)> SaveChanges(SaveMode mode, bool commit) + public async Task<(int created, int updated)> SaveChanges(SaveMode mode, bool commit, CancellationToken cancellationToken) { switch (mode) { @@ -94,17 +95,20 @@ public WorkItemWrapper NewWorkItem(string workItemType, string projectName = nul _context.Logger.WriteVerbose($"No save mode specified, assuming {SaveMode.TwoPhases}."); goto case SaveMode.TwoPhases; case SaveMode.Item: - return await SaveChanges_ByItem(commit); + var resultItem = await SaveChanges_ByItem(commit, cancellationToken); + return resultItem; case SaveMode.Batch: - return await SaveChanges_Batch(commit); + var resultBatch = await SaveChanges_Batch(commit, cancellationToken); + return resultBatch; case SaveMode.TwoPhases: - return await SaveChanges_TwoPhases(commit); + var resultTwoPhases = await SaveChanges_TwoPhases(commit, cancellationToken); + return resultTwoPhases; default: throw new ApplicationException($"Unsupported save mode: {mode}."); } } - private async Task<(int created, int updated)> SaveChanges_ByItem(bool commit) + private async Task<(int created, int updated)> SaveChanges_ByItem(bool commit, CancellationToken cancellationToken) { int created = 0; int updated = 0; @@ -113,16 +117,18 @@ public WorkItemWrapper NewWorkItem(string workItemType, string projectName = nul if (commit) { _context.Logger.WriteInfo($"Creating a {item.WorkItemType} workitem in {item.TeamProject}"); - var wi = await _context.Client.CreateWorkItemAsync( + _ = await _context.Client.CreateWorkItemAsync( item.Changes, _context.ProjectId, - item.WorkItemType + item.WorkItemType, + cancellationToken: cancellationToken ); } else { _context.Logger.WriteInfo($"Dry-run mode: should create a {item.WorkItemType} workitem in {item.TeamProject}"); } + created++; } @@ -131,21 +137,24 @@ public WorkItemWrapper NewWorkItem(string workItemType, string projectName = nul if (commit) { _context.Logger.WriteInfo($"Updating workitem {item.Id}"); - var wi = await _context.Client.UpdateWorkItemAsync( + _ = await _context.Client.UpdateWorkItemAsync( item.Changes, - item.Id.Value + item.Id.Value, + cancellationToken: cancellationToken ); } else { _context.Logger.WriteInfo($"Dry-run mode: should update workitem {item.Id} in {item.TeamProject}"); } + updated++; } + return (created, updated); } - private async Task<(int created, int updated)> SaveChanges_Batch(bool commit) + private async Task<(int created, int updated)> SaveChanges_Batch(bool commit, CancellationToken cancellationToken) { // see https://github.com/redarrowlabs/vsts-restapi-samplecode/blob/master/VSTSRestApiSamples/WorkItemTracking/Batch.cs // and https://docs.microsoft.com/en-us/rest/api/vsts/wit/workitembatchupdate?view=vsts-rest-4.1 @@ -159,10 +168,10 @@ public WorkItemWrapper NewWorkItem(string workItemType, string projectName = nul string baseUriString = _context.Client.BaseAddress.AbsoluteUri; BatchRequest[] batchRequests = new BatchRequest[created + updated]; - Dictionary headers = new Dictionary() { + Dictionary headers = new Dictionary { { "Content-Type", "application/json-patch+json" } }; - string credentials = Convert.ToBase64String(ASCIIEncoding.ASCII.GetBytes($":{_context.PersonalAccessToken}")); + string credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($":{_context.PersonalAccessToken}")); int index = 0; @@ -211,11 +220,11 @@ public WorkItemWrapper NewWorkItem(string workItemType, string projectName = nul // send the request var request = new HttpRequestMessage(method, $"{baseUriString}/_apis/wit/$batch?{ApiVersion}") { Content = batchRequest }; - var response = client.SendAsync(request).Result; + var response = await client.SendAsync(request, cancellationToken); if (response.IsSuccessStatusCode) { - WorkItemBatchPostResponse batchResponse = response.Content.ReadAsAsync().Result; + WorkItemBatchPostResponse batchResponse = await response.Content.ReadAsAsync(cancellationToken); string stringResponse = JsonConvert.SerializeObject(batchResponse, Formatting.Indented); _context.Logger.WriteVerbose(stringResponse); bool succeeded = true; @@ -246,7 +255,7 @@ public WorkItemWrapper NewWorkItem(string workItemType, string projectName = nul return (created, updated); } - private async Task<(int created, int updated)> SaveChanges_TwoPhases(bool commit) + private async Task<(int created, int updated)> SaveChanges_TwoPhases(bool commit, CancellationToken cancellationToken) { // see https://github.com/redarrowlabs/vsts-restapi-samplecode/blob/master/VSTSRestApiSamples/WorkItemTracking/Batch.cs // and https://docs.microsoft.com/en-us/rest/api/vsts/wit/workitembatchupdate?view=vsts-rest-4.1 @@ -280,7 +289,7 @@ public WorkItemWrapper NewWorkItem(string workItemType, string projectName = nul }; } - var batchResponse = await proxy.Invoke(newWorkItemsBatchRequests); + var batchResponse = await proxy.InvokeAsync(newWorkItemsBatchRequests, cancellationToken); if (batchResponse != null) { _context.Logger.WriteVerbose($"Updating work item ids..."); @@ -324,7 +333,7 @@ public WorkItemWrapper NewWorkItem(string workItemType, string projectName = nul } // return value not used, we are fine if no exception is thrown - await proxy.Invoke(batchRequests.ToArray()); + await proxy.InvokeAsync(batchRequests.ToArray(), cancellationToken); return (created, updated); } diff --git a/src/unittests-ruleng/RuleTests.cs b/src/unittests-ruleng/RuleTests.cs index dd68c837..fbc477e4 100644 --- a/src/unittests-ruleng/RuleTests.cs +++ b/src/unittests-ruleng/RuleTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; using aggregator; using aggregator.Engine; using Microsoft.TeamFoundation.WorkItemTracking.WebApi; @@ -48,7 +49,7 @@ public async void HelloWorldRule_Succeeds() "; var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); + string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client, CancellationToken.None); Assert.Equal("Hello Bug #42 - Hello!", result); } @@ -71,7 +72,7 @@ public async void LanguageDirective_Succeeds() "; var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); + string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client, CancellationToken.None); Assert.Equal(EngineState.Success, engine.State); Assert.Equal(string.Empty, result); @@ -95,7 +96,7 @@ public async void LanguageDirective_Fails() "; var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); + string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client, CancellationToken.None); Assert.Equal(EngineState.Error, engine.State); } @@ -149,7 +150,7 @@ public async void Parent_Succeeds() "; var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); + string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client, CancellationToken.None); Assert.Equal("Parent is 1", result); } @@ -173,7 +174,7 @@ public async void New_Succeeds() "; var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); + string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client, CancellationToken.None); Assert.Null(result); logger.Received().WriteInfo($"Found a request for a new Task workitem in {projectName}"); @@ -202,7 +203,7 @@ public async void AddChild_Succeeds() "; var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); + string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client, CancellationToken.None); Assert.Null(result); logger.Received().WriteInfo($"Found a request for a new Task workitem in {projectName}"); @@ -229,7 +230,7 @@ public async void TouchDescription_Succeedes() "; var engine = new RuleEngine(logger, ruleCode.Mince(), SaveMode.Default, dryRun: true); - string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client); + string result = await engine.ExecuteAsync(collectionUrl, projectId, projectName, personalAccessToken, workItemId, client, CancellationToken.None); Assert.Equal("Hello.", result); logger.Received().WriteInfo($"Found a request to update workitem {workItemId} in {projectName}"); diff --git a/src/unittests-ruleng/WorkItemStoreTests.cs b/src/unittests-ruleng/WorkItemStoreTests.cs index 62c602a2..7e56863a 100644 --- a/src/unittests-ruleng/WorkItemStoreTests.cs +++ b/src/unittests-ruleng/WorkItemStoreTests.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using aggregator; using aggregator.Engine; using Microsoft.TeamFoundation.WorkItemTracking.WebApi; @@ -71,7 +73,7 @@ public void GetWorkItems_ByIds_Succeeds() } [Fact] - public void NewWorkItem_Succeeds() + public async Task NewWorkItem_Succeeds() { var logger = Substitute.For(); var client = Substitute.For(new Uri($"{collectionUrl}"), null); @@ -80,7 +82,7 @@ public void NewWorkItem_Succeeds() var wi = sut.NewWorkItem("Task"); wi.Title = "Brand new"; - var save = sut.SaveChanges(SaveMode.Default, false).Result; + var save = await sut.SaveChanges(SaveMode.Default, false, CancellationToken.None); Assert.NotNull(wi); Assert.True(wi.IsNew);