diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Azure.Sdk.Tools.PipelineWitness.csproj b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Azure.Sdk.Tools.PipelineWitness.csproj index 44c05d553d9..34e190e3427 100644 --- a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Azure.Sdk.Tools.PipelineWitness.csproj +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Azure.Sdk.Tools.PipelineWitness.csproj @@ -10,6 +10,7 @@ + diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/BlobUploadProcessor.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/BlobUploadProcessor.cs index e5453307496..60257722f67 100644 --- a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/BlobUploadProcessor.cs +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/BlobUploadProcessor.cs @@ -13,11 +13,13 @@ namespace Azure.Sdk.Tools.PipelineWitness using System.Text.RegularExpressions; using System.Threading.Tasks; + using Azure.Sdk.Tools.PipelineWitness.Configuration; using Azure.Sdk.Tools.PipelineWitness.Services; using Azure.Sdk.Tools.PipelineWitness.Services.FailureAnalysis; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Azure.Storage.Queues; + using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.TeamFoundation.Build.WebApi; diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Configuration/ISecretClientProvider.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Configuration/ISecretClientProvider.cs new file mode 100644 index 00000000000..3b6bb5cd419 --- /dev/null +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Configuration/ISecretClientProvider.cs @@ -0,0 +1,11 @@ +using System; + +using Azure.Security.KeyVault.Secrets; + +namespace Azure.Sdk.Tools.PipelineWitness.Configuration +{ + public interface ISecretClientProvider + { + SecretClient GetSecretClient(Uri vaultUri); + } +} diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/PipelineWitnessSettings.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Configuration/PipelineWitnessSettings.cs similarity index 73% rename from tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/PipelineWitnessSettings.cs rename to tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Configuration/PipelineWitnessSettings.cs index f4c46c3d912..2109b592328 100644 --- a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/PipelineWitnessSettings.cs +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Configuration/PipelineWitnessSettings.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using System.Runtime.Serialization; -namespace Azure.Sdk.Tools.PipelineWitness +namespace Azure.Sdk.Tools.PipelineWitness.Configuration { public class PipelineWitnessSettings { @@ -12,12 +12,17 @@ public class PipelineWitnessSettings public string KeyVaultUri { get; set; } /// - /// Gets or sets uri of the storage account use for queue processing + /// Gets or sets uri of the cosmos account to use + /// + public string CosmosAccountUri { get; set; } + + /// + /// Gets or sets uri of the storage account to use for queue processing /// public string QueueStorageAccountUri { get; set; } /// - /// Gets or sets uri of the blob storage account use for blob export + /// Gets or sets uri of the blob storage account to use for blob export /// public string BlobStorageAccountUri { get; set; } @@ -80,5 +85,25 @@ public class PipelineWitnessSettings /// Gets or sets the definition id of the pipeline owners extraction build /// public int PipelineOwnersDefinitionId { get; set; } + + /// + /// Gets or sets the database to use + /// + public string CosmosDatabase { get; set; } + + /// + /// Gets or sets the container to use for async locks + /// + public string CosmosAsyncLockContainer { get; set; } + + /// + /// Gets or sets the authorization key for the Cosmos account + /// + public string CosmosAuthorizationKey { get; set; } + + /// + /// Gets or sets the access token to use for Azure DevOps clients + /// + public string DevopsAccessToken { get; set; } } } diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Configuration/PostConfigureKeyVaultSettings.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Configuration/PostConfigureKeyVaultSettings.cs new file mode 100644 index 00000000000..10a5200557e --- /dev/null +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Configuration/PostConfigureKeyVaultSettings.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Azure.Sdk.Tools.PipelineWitness.Configuration +{ + public class PostConfigureKeyVaultSettings : IPostConfigureOptions where T : class + { + private static readonly Regex secretRegex = new Regex(@"(?https://.*?\.vault\.azure\.net)/secrets/(?.*)", RegexOptions.Compiled, TimeSpan.FromSeconds(5)); + private readonly ILogger logger; + private readonly ISecretClientProvider secretClientProvider; + + public PostConfigureKeyVaultSettings(ILogger> logger, ISecretClientProvider secretClientProvider) + { + this.logger = logger; + this.secretClientProvider = secretClientProvider; + } + + public void PostConfigure(string name, T options) + { + var stringProperties = typeof(T) + .GetProperties() + .Where(x => x.PropertyType == typeof(string)); + + foreach (var property in stringProperties) + { + var value = (string)property.GetValue(options); + + if (value != null) + { + var match = secretRegex.Match(value); + + if (match.Success) + { + var vaultUrl = match.Groups["vault"].Value; + var secretName = match.Groups["secret"].Value; + + try + { + var secretClient = this.secretClientProvider.GetSecretClient(new Uri(vaultUrl)); + this.logger.LogInformation("Replacing setting property {PropertyName} with value from secret {SecretUrl}", property.Name, value); + + var response = secretClient.GetSecret(secretName); + var secret = response.Value; + + property.SetValue(options, secret.Value); + } + catch (Exception exception) + { + this.logger.LogError(exception, "Unable to read secret {SecretName} from vault {VaultUrl}", secretName, vaultUrl); + } + } + } + } + } + } +} diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Configuration/SecretClientProvider.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Configuration/SecretClientProvider.cs new file mode 100644 index 00000000000..54e69ac9450 --- /dev/null +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Configuration/SecretClientProvider.cs @@ -0,0 +1,22 @@ +using System; + +using Azure.Core; +using Azure.Security.KeyVault.Secrets; + +namespace Azure.Sdk.Tools.PipelineWitness.Configuration +{ + public class SecretClientProvider : ISecretClientProvider + { + private readonly TokenCredential tokenCredential; + + public SecretClientProvider(TokenCredential tokenCredential) + { + this.tokenCredential = tokenCredential; + } + + public SecretClient GetSecretClient(Uri vaultUri) + { + return new SecretClient(vaultUri, this.tokenCredential); + } + } +} diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/CosmosDatabaseInitializer.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/CosmosDatabaseInitializer.cs new file mode 100644 index 00000000000..9fa992159b6 --- /dev/null +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/CosmosDatabaseInitializer.cs @@ -0,0 +1,43 @@ +using System.Threading; +using System.Threading.Tasks; + +using Azure.Sdk.Tools.PipelineWitness.Configuration; + +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace Azure.Sdk.Tools.PipelineWitness +{ + internal class CosmosDatabaseInitializer : IHostedService + { + private readonly CosmosClient cosmosClient; + private readonly IOptions options; + + public CosmosDatabaseInitializer(CosmosClient cosmosClient, IOptions options) + { + this.cosmosClient = cosmosClient; + this.options = options; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var settings = this.options.Value; + + Database database = await this.cosmosClient.CreateDatabaseIfNotExistsAsync(settings.CosmosDatabase, cancellationToken: cancellationToken); + + await database.CreateContainerIfNotExistsAsync( + new ContainerProperties + { + Id = settings.CosmosAsyncLockContainer, + PartitionKeyPath = "/id", + }, + cancellationToken: cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/AzurePipelinesBuildDefinitionWorker.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/AzurePipelinesBuildDefinitionWorker.cs index 3e2f411e464..f47571155e7 100644 --- a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/AzurePipelinesBuildDefinitionWorker.cs +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/AzurePipelinesBuildDefinitionWorker.cs @@ -6,6 +6,8 @@ using Microsoft.VisualStudio.Services.WebApi; using System.Diagnostics; using Microsoft.Extensions.Options; +using Azure.Sdk.Tools.PipelineWitness.Services.WorkTokens; +using Azure.Sdk.Tools.PipelineWitness.Configuration; namespace Azure.Sdk.Tools.PipelineWitness.Services { @@ -14,33 +16,51 @@ public class AzurePipelinesBuildDefinitionWorker : BackgroundService private readonly ILogger logger; private readonly BlobUploadProcessor runProcessor; private readonly IOptions options; + private IAsyncLockProvider asyncLockProvider; public AzurePipelinesBuildDefinitionWorker( ILogger logger, BlobUploadProcessor runProcessor, + IAsyncLockProvider asyncLockProvider, IOptions options) { this.logger = logger; this.runProcessor = runProcessor; this.options = options; + this.asyncLockProvider = asyncLockProvider; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + var processEvery = TimeSpan.FromMinutes(60); + while (true) { var stopWatch = Stopwatch.StartNew(); var settings = this.options.Value; - foreach (var project in settings.Projects) + try + { + await using var asyncLock = await this.asyncLockProvider.GetLockAsync("UpdateBuildDefinitions", processEvery, stoppingToken); + + // if there's no asyncLock, this process has alread completed in the last hour + if (asyncLock != null) + { + foreach (var project in settings.Projects) + { + await this.runProcessor.UploadBuildDefinitionBlobsAsync(settings.Account, project); + } + } + } + catch(Exception ex) { - await this.runProcessor.UploadBuildDefinitionBlobsAsync(settings.Account, project); + this.logger.LogError(ex, "Error processing build definitions"); } var duration = settings.BuildDefinitionLoopPeriod - stopWatch.Elapsed; if (duration > TimeSpan.Zero) { - await Task.Delay(duration); + await Task.Delay(duration, stoppingToken); } } } diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/BuildCompleteQueueWorker.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/BuildCompleteQueueWorker.cs index 0ece5098db3..387d33be904 100644 --- a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/BuildCompleteQueueWorker.cs +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/BuildCompleteQueueWorker.cs @@ -2,12 +2,16 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; + +using Azure.Sdk.Tools.PipelineWitness.Configuration; using Azure.Storage.Queues; using Azure.Storage.Queues.Models; + using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.DataContracts; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; + using Newtonsoft.Json.Linq; namespace Azure.Sdk.Tools.PipelineWitness.Services diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/EnhancedBuildHttpClient.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/EnhancedBuildHttpClient.cs index 631ea1dac9d..8fabcb06b0a 100644 --- a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/EnhancedBuildHttpClient.cs +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/EnhancedBuildHttpClient.cs @@ -7,62 +7,63 @@ using Microsoft.TeamFoundation.Build.WebApi; using Microsoft.VisualStudio.Services.Common; -namespace Azure.Sdk.Tools.PipelineWitness.Services; - -public class EnhancedBuildHttpClient : BuildHttpClient - +namespace Azure.Sdk.Tools.PipelineWitness.Services { - public EnhancedBuildHttpClient(Uri baseUrl, VssCredentials credentials) - : base(baseUrl, credentials) - {} + public class EnhancedBuildHttpClient : BuildHttpClient - public EnhancedBuildHttpClient(Uri baseUrl, VssCredentials credentials, VssHttpRequestSettings settings) - : base(baseUrl, credentials, settings) - {} + { + public EnhancedBuildHttpClient(Uri baseUrl, VssCredentials credentials) + : base(baseUrl, credentials) + {} - public EnhancedBuildHttpClient(Uri baseUrl, VssCredentials credentials, params DelegatingHandler[] handlers) - : base(baseUrl, credentials, handlers) - {} + public EnhancedBuildHttpClient(Uri baseUrl, VssCredentials credentials, VssHttpRequestSettings settings) + : base(baseUrl, credentials, settings) + {} - public EnhancedBuildHttpClient(Uri baseUrl, VssCredentials credentials, VssHttpRequestSettings settings, params DelegatingHandler[] handlers) - : base(baseUrl, credentials, settings, handlers) - {} + public EnhancedBuildHttpClient(Uri baseUrl, VssCredentials credentials, params DelegatingHandler[] handlers) + : base(baseUrl, credentials, handlers) + {} - public EnhancedBuildHttpClient(Uri baseUrl, HttpMessageHandler pipeline, bool disposeHandler) - : base(baseUrl, pipeline, disposeHandler) - {} + public EnhancedBuildHttpClient(Uri baseUrl, VssCredentials credentials, VssHttpRequestSettings settings, params DelegatingHandler[] handlers) + : base(baseUrl, credentials, settings, handlers) + {} - public override async Task GetArtifactContentZipAsync( - Guid project, - int buildId, - string artifactName, - object userState = null, - CancellationToken cancellationToken = default) - { - var artifact = await base.GetArtifactAsync(project, buildId, artifactName, userState, cancellationToken); - return await GetArtifactContentZipAsync(artifact, cancellationToken); - } + public EnhancedBuildHttpClient(Uri baseUrl, HttpMessageHandler pipeline, bool disposeHandler) + : base(baseUrl, pipeline, disposeHandler) + {} - public override async Task GetArtifactContentZipAsync( - string project, - int buildId, - string artifactName, - object userState = null, - CancellationToken cancellationToken = default) - { - var artifact = await base.GetArtifactAsync(project, buildId, artifactName, userState, cancellationToken); - return await GetArtifactContentZipAsync(artifact, cancellationToken); - } + public override async Task GetArtifactContentZipAsync( + Guid project, + int buildId, + string artifactName, + object userState = null, + CancellationToken cancellationToken = default) + { + var artifact = await base.GetArtifactAsync(project, buildId, artifactName, userState, cancellationToken); + return await GetArtifactContentZipAsync(artifact, cancellationToken); + } - private async Task GetArtifactContentZipAsync(BuildArtifact artifact, CancellationToken cancellationToken) - { - var downloadUrl = artifact?.Resource?.DownloadUrl; - if (string.IsNullOrWhiteSpace(downloadUrl)) + public override async Task GetArtifactContentZipAsync( + string project, + int buildId, + string artifactName, + object userState = null, + CancellationToken cancellationToken = default) { - throw new InvalidArtifactDataException("Artifact contained no download url"); + var artifact = await base.GetArtifactAsync(project, buildId, artifactName, userState, cancellationToken); + return await GetArtifactContentZipAsync(artifact, cancellationToken); } - var responseStream = await Client.GetStreamAsync(downloadUrl, cancellationToken); - return responseStream; + private async Task GetArtifactContentZipAsync(BuildArtifact artifact, CancellationToken cancellationToken) + { + var downloadUrl = artifact?.Resource?.DownloadUrl; + if (string.IsNullOrWhiteSpace(downloadUrl)) + { + throw new InvalidArtifactDataException("Artifact contained no download url"); + } + + var responseStream = await Client.GetStreamAsync(downloadUrl, cancellationToken); + return responseStream; + } } } diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/QueueWorkerBackgroundService.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/QueueWorkerBackgroundService.cs index ba26736f419..43b0674e788 100644 --- a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/QueueWorkerBackgroundService.cs +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/QueueWorkerBackgroundService.cs @@ -3,8 +3,11 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; + +using Azure.Sdk.Tools.PipelineWitness.Configuration; using Azure.Storage.Queues; using Azure.Storage.Queues.Models; + using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.DataContracts; using Microsoft.Extensions.Hosting; diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/CosmosAsyncLock.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/CosmosAsyncLock.cs new file mode 100644 index 00000000000..91d98f6523b --- /dev/null +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/CosmosAsyncLock.cs @@ -0,0 +1,66 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.Cosmos; + +namespace Azure.Sdk.Tools.PipelineWitness.Services.WorkTokens +{ + class CosmosAsyncLock : IAsyncLock + { + private readonly string id; + private readonly PartitionKey partitionKey; + private readonly TimeSpan duration; + private readonly Container container; + private string etag; + + public CosmosAsyncLock(string id, string etag, TimeSpan duration, Container container) + { + this.id = id; + this.partitionKey = new PartitionKey(id); + this.etag = etag; + this.duration = duration; + this.container = container; + } + + public bool ReleaseOnDispose { get; set; } + + public async ValueTask DisposeAsync() + { + if (ReleaseOnDispose) + { + try + { + await this.container.DeleteItemAsync(this.id, this.partitionKey, new ItemRequestOptions { IfMatchEtag = this.etag }); + } + catch(CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + } + } + } + + public async Task TryRenewAsync(CancellationToken cancellationToken) + { + try + { + var response = await this.container.ReplaceItemAsync( + new CosmosLockDocument(this.id, this.duration), + this.id, + this.partitionKey, + new ItemRequestOptions { IfMatchEtag = this.etag }, + cancellationToken); + + if (response?.StatusCode == HttpStatusCode.OK) + { + this.etag = response.ETag; + return true; + } + } + catch (CosmosException ex) when(ex.StatusCode == HttpStatusCode.Conflict) + { + } + + return false; + } + } +} diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/CosmosAsyncLockProvider.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/CosmosAsyncLockProvider.cs new file mode 100644 index 00000000000..ff695c4f138 --- /dev/null +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/CosmosAsyncLockProvider.cs @@ -0,0 +1,85 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Azure.Cosmos; + +namespace Azure.Sdk.Tools.PipelineWitness.Services.WorkTokens +{ + public class CosmosAsyncLockProvider : IAsyncLockProvider + { + private readonly Container container; + + public CosmosAsyncLockProvider(CosmosClient cosmosClient, string databaseName, string containerName) + { + if (cosmosClient == null) + { + throw new ArgumentNullException(nameof(cosmosClient)); + } + + this.container = cosmosClient.GetContainer(databaseName, containerName); + } + + public async Task GetLockAsync(string id, TimeSpan duration, CancellationToken cancellationToken) + { + var partitionKey = new PartitionKey(id); + + ItemResponse response; + + try + { + response = await this.container.ReadItemAsync(id, partitionKey, cancellationToken: cancellationToken); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + return await CreateLockAsync(id, duration, cancellationToken); + } + + var existingLock = response.Resource; + + if (existingLock.Expiration >= DateTime.UtcNow) + { + return null; + } + + try + { + response = await this.container.ReplaceItemAsync( + new CosmosLockDocument(id, duration), + id, + partitionKey, + new ItemRequestOptions { IfMatchEtag = response.ETag }, + cancellationToken); + + if (response.StatusCode == HttpStatusCode.OK) + { + return new CosmosAsyncLock(id, response.ETag, duration, this.container); + } + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + } + + return null; + } + + private async Task CreateLockAsync(string id, TimeSpan duration, CancellationToken cancellationToken) + { + try + { + var response = await this.container.CreateItemAsync( + new CosmosLockDocument(id, duration), + new PartitionKey(id), + cancellationToken: cancellationToken); + + return new CosmosAsyncLock(id, response.ETag, duration, this.container); + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + } + + return null; + } + } +} diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/CosmosLockDocument.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/CosmosLockDocument.cs new file mode 100644 index 00000000000..8b57daaa6bf --- /dev/null +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/CosmosLockDocument.cs @@ -0,0 +1,24 @@ +using System; +using Newtonsoft.Json; + +namespace Azure.Sdk.Tools.PipelineWitness.Services.WorkTokens +{ + public class CosmosLockDocument + { + public CosmosLockDocument() + { + } + + public CosmosLockDocument(string id, TimeSpan duration) + { + Id = id; + Expiration = DateTime.UtcNow.Add(duration); + } + + [JsonProperty("id")] + public string Id { get; set; } + + [JsonProperty("expiration")] + public DateTime Expiration { get; set; } + } +} diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/IAsyncLock.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/IAsyncLock.cs new file mode 100644 index 00000000000..74607d13ef8 --- /dev/null +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/IAsyncLock.cs @@ -0,0 +1,13 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Azure.Sdk.Tools.PipelineWitness.Services.WorkTokens +{ + public interface IAsyncLock : IAsyncDisposable + { + bool ReleaseOnDispose { get; set; } + + Task TryRenewAsync(CancellationToken cancellationToken); + } +} diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/IAsyncLockProvider.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/IAsyncLockProvider.cs new file mode 100644 index 00000000000..5589bf3804a --- /dev/null +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Services/WorkTokens/IAsyncLockProvider.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Azure.Sdk.Tools.PipelineWitness.Services.WorkTokens +{ + public interface IAsyncLockProvider + { + Task GetLockAsync(string id, TimeSpan duration, CancellationToken cancellationToken); + } +} diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Startup.cs b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Startup.cs index 7112acdfadb..a64b4e7ef50 100644 --- a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Startup.cs +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/Startup.cs @@ -1,15 +1,21 @@ using System; + +using Azure.Core; using Azure.Identity; using Azure.Sdk.Tools.PipelineWitness.ApplicationInsights; +using Azure.Sdk.Tools.PipelineWitness.Configuration; using Azure.Sdk.Tools.PipelineWitness.Services; using Azure.Sdk.Tools.PipelineWitness.Services.FailureAnalysis; -using Azure.Security.KeyVault.Secrets; +using Azure.Sdk.Tools.PipelineWitness.Services.WorkTokens; + using Microsoft.ApplicationInsights.Extensibility; using Microsoft.AspNetCore.Builder; +using Microsoft.Azure.Cosmos; using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using Microsoft.TeamFoundation.Build.WebApi; using Microsoft.TeamFoundation.Core.WebApi; using Microsoft.VisualStudio.Services.Common; @@ -37,17 +43,22 @@ public static void Configure(WebApplicationBuilder builder) builder.AddBlobServiceClient(new Uri(settings.BlobStorageAccountUri)); builder.AddQueueServiceClient(new Uri(settings.QueueStorageAccountUri)) .ConfigureOptions(o => o.MessageEncoding = Storage.Queues.QueueMessageEncoding.Base64); + + builder.AddClient((CosmosClientOptions options, IServiceProvider provider) => + { + var resolvedSettings = provider.GetRequiredService>().Value; + return new CosmosClient(settings.CosmosAccountUri, resolvedSettings.CosmosAuthorizationKey, options); + }); }); builder.Services.AddSingleton(provider => { - var secretClient = provider.GetRequiredService(); - KeyVaultSecret secret = secretClient.GetSecret("azure-devops-personal-access-token"); - var credential = new VssBasicCredential("nobody", secret.Value); - var connection = new VssConnection(new Uri("https://dev.azure.com/azure-sdk"), credential); - return connection; + var resolvedSettings = provider.GetRequiredService>().Value; + var credential = new VssBasicCredential("nobody", resolvedSettings.DevopsAccessToken); + return new VssConnection(new Uri("https://dev.azure.com/azure-sdk"), credential); }); + builder.Services.AddSingleton(provider => new CosmosAsyncLockProvider(provider.GetRequiredService(), settings.CosmosDatabase, settings.CosmosAsyncLockContainer)); builder.Services.AddSingleton(provider => provider.GetRequiredService().GetClient()); builder.Services.AddSingleton(provider => provider.GetRequiredService().GetClient()); builder.Services.AddSingleton(provider => provider.GetRequiredService().GetClient()); @@ -76,8 +87,13 @@ public static void Configure(WebApplicationBuilder builder) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.Configure(settingsSection); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton, PostConfigureKeyVaultSettings>(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(settings.BuildCompleteWorkerCount); builder.Services.AddHostedService(); } diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/appsettings.development.json b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/appsettings.development.json index 467aae82ead..cadd0c09d93 100644 --- a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/appsettings.development.json +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/appsettings.development.json @@ -24,6 +24,9 @@ "KeyVaultUri": "https://pipelinewitnessstaging.vault.azure.net/", "QueueStorageAccountUri": "https://pipelinewitnessstaging.queue.core.windows.net", "BlobStorageAccountUri": "https://pipelinelogsstaging.blob.core.windows.net", + "CosmosAccountUri": "https://pipelinewitnessstaging.documents.azure.com", + "CosmosAuthorizationKey": "https://pipelinewitnessstaging.vault.azure.net/secrets/cosmosdb-primary-authorization-key", + "DevopsAccessToken": "https://pipelinewitnessstaging.vault.azure.net/secrets/azure-devops-personal-access-token", "BuildDefinitionLoopPeriod": "00:01:00", "BuildCompleteWorkerCount": 1, "BuildLogBundlesWorkerCount": 1 diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/appsettings.json b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/appsettings.json index c9b5aa4e775..8a4c53632ac 100644 --- a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/appsettings.json +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/appsettings.json @@ -13,8 +13,13 @@ "KeyVaultUri": "https://pipelinewitnessprod.vault.azure.net/", "QueueStorageAccountUri": "https://pipelinewitnessprod.queue.core.windows.net", "BlobStorageAccountUri": "https://azsdkengsyspipelinelogs.blob.core.windows.net", + "CosmosAccountUri": "https://pipelinewitnessprod.documents.azure.com", + "CosmosAuthorizationKey": "https://pipelinewitnessprod.vault.azure.net/secrets/cosmosdb-primary-authorization-key", + "DevopsAccessToken": "https://pipelinewitnessprod.vault.azure.net/secrets/azure-devops-personal-access-token", + "CosmosDatabase": "records", + "CosmosAsyncLockContainer": "locks", "BuildCompleteQueueName": "azurepipelines-build-completed", - "BuildCompleteWorkerCount": 5, + "BuildCompleteWorkerCount": 10, "MessageLeasePeriod": "00:03:00", "MessageErrorSleepPeriod": "00:00:10", "MaxDequeueCount": 5, diff --git a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/appsettings.staging.json b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/appsettings.staging.json index b7b1f5c26a2..c56fadaf167 100644 --- a/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/appsettings.staging.json +++ b/tools/pipeline-witness/Azure.Sdk.Tools.PipelineWitness/appsettings.staging.json @@ -2,6 +2,9 @@ "PipelineWitness": { "KeyVaultUri": "https://pipelinewitnessstaging.vault.azure.net/", "QueueStorageAccountUri": "https://pipelinewitnessstaging.queue.core.windows.net", - "BlobStorageAccountUri": "https://pipelinelogsstaging.blob.core.windows.net" + "BlobStorageAccountUri": "https://pipelinelogsstaging.blob.core.windows.net", + "CosmosAccountUri": "https://pipelinewitnessstaging.documents.azure.com", + "CosmosAuthorizationKey": "https://pipelinewitnessstaging.vault.azure.net/secrets/cosmosdb-primary-authorization-key", + "DevopsAccessToken": "https://pipelinewitnessstaging.vault.azure.net/secrets/azure-devops-personal-access-token", } }