From c05a14eb178629c8753bb69cabd2e9ab27d88e31 Mon Sep 17 00:00:00 2001 From: Praveen Kuttappan Date: Sat, 13 Apr 2024 01:26:24 -0400 Subject: [PATCH 1/3] Authenticate requests from APIView to DevOps using managed Identity --- .../APIView/APIViewWeb/APIViewWeb.csproj | 8 +- .../Repositories/DevopsArtifactRepository.cs | 172 ++++++++---------- 2 files changed, 79 insertions(+), 101 deletions(-) diff --git a/src/dotnet/APIView/APIViewWeb/APIViewWeb.csproj b/src/dotnet/APIView/APIViewWeb/APIViewWeb.csproj index 81f69826d59..9957e6ba219 100644 --- a/src/dotnet/APIView/APIViewWeb/APIViewWeb.csproj +++ b/src/dotnet/APIView/APIViewWeb/APIViewWeb.csproj @@ -26,7 +26,7 @@ - + @@ -45,12 +45,14 @@ - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + diff --git a/src/dotnet/APIView/APIViewWeb/Repositories/DevopsArtifactRepository.cs b/src/dotnet/APIView/APIViewWeb/Repositories/DevopsArtifactRepository.cs index fbaf660f9b2..3d8d8c94bc4 100644 --- a/src/dotnet/APIView/APIViewWeb/Repositories/DevopsArtifactRepository.cs +++ b/src/dotnet/APIView/APIViewWeb/Repositories/DevopsArtifactRepository.cs @@ -1,130 +1,106 @@ -using Microsoft.ApplicationInsights.Extensibility; -using Microsoft.ApplicationInsights; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Caching.Memory; +using Azure.Core; +using Azure.Identity; +using Microsoft.ApplicationInsights; using Microsoft.Extensions.Configuration; -using Microsoft.TeamFoundation.Build.WebApi; -using Microsoft.TeamFoundation.Core.WebApi; -using Microsoft.VisualStudio.Services.Common; -using Microsoft.VisualStudio.Services.WebApi; -using Newtonsoft.Json; -using Octokit; +using Microsoft.TeamFoundation.Build.WebApi; +using Microsoft.TeamFoundation.Core.WebApi; +using Microsoft.VisualStudio.Services.Client; +using Microsoft.VisualStudio.Services.WebApi; +using Newtonsoft.Json; +using Polly; using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net; using System.Net.Http; -using System.Net.Http.Headers; -using System.Text.Json; +using System.Threading; using System.Threading.Tasks; namespace APIViewWeb.Repositories { public class DevopsArtifactRepository : IDevopsArtifactRepository { - private readonly HttpClient _devopsClient; private readonly IConfiguration _configuration; - private readonly string _devopsAccessToken; private readonly string _hostUrl; private readonly TelemetryClient _telemetryClient; public DevopsArtifactRepository(IConfiguration configuration, TelemetryClient telemetryClient) { _configuration = configuration; - _devopsAccessToken = Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes(string.Format("{0}:{1}", "", _configuration["Azure-Devops-PAT"]))); _hostUrl = _configuration["APIVIew-Host-Url"]; _telemetryClient = telemetryClient; - - _devopsClient = new HttpClient(); - _devopsClient.DefaultRequestHeaders.Accept.Clear(); - _devopsClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - _devopsClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", _devopsAccessToken); } public async Task DownloadPackageArtifact(string repoName, string buildId, string artifactName, string filePath, string project, string format= "file") { - var downloadUrl = await GetDownloadArtifactUrl(repoName, buildId, artifactName, project); - if (!string.IsNullOrEmpty(downloadUrl)) + var downloadUrl = await getDownloadArtifactUrl(buildId, artifactName, project); + if (string.IsNullOrEmpty(downloadUrl)) + { + throw new Exception(string.Format("Failed to get download url for artifact {0} in build {1} in project {2}", artifactName, buildId, project)); + } + + if(!string.IsNullOrEmpty(filePath)) { - if(!string.IsNullOrEmpty(filePath)) + if (!filePath.StartsWith("/")) { - if (!filePath.StartsWith("/")) - { - filePath = "/" + filePath; - } - downloadUrl = downloadUrl.Split("?")[0] + "?format=" + format + "&subPath=" + filePath; + filePath = "/" + filePath; } - - var downloadResp = await GetFromDevopsAsync(downloadUrl); - downloadResp.EnsureSuccessStatusCode(); - return await downloadResp.Content.ReadAsStreamAsync(); + downloadUrl = downloadUrl.Split("?")[0] + "?format=" + format + "&subPath=" + filePath; } - return null; + + HttpResponseMessage downloadResp = await GetFromDevopsAsync(downloadUrl); + downloadResp.EnsureSuccessStatusCode(); + return await downloadResp.Content.ReadAsStreamAsync(); } - private async Task GetFromDevopsAsync(string request) + private async Task getDownloadArtifactUrl(string buildId, string artifactName, string project) { - var downloadResp = await _devopsClient.GetAsync(request); - - - if (!downloadResp.IsSuccessStatusCode) - { - var retryAfter = downloadResp.Headers.GetValues("Retry-After"); - var rateLimitResource = downloadResp.Headers.GetValues("X-RateLimit-Resource"); - var rateLimitDelay = downloadResp.Headers.GetValues("X-RateLimit-Delay"); - var rateLimitLimit = downloadResp.Headers .GetValues("X-RateLimit-Limit"); - var rateLimitRemaining = downloadResp.Headers.GetValues("X-RateLimit-Remaining"); - var rateLimitReset = downloadResp.Headers.GetValues("X-RateLimit-Reset"); - - var traceMessage = $"request: {request} failed with statusCode: {downloadResp.StatusCode}," + - $"retryAfter: {retryAfter.FirstOrDefault()}, rateLimitResource: {rateLimitResource.FirstOrDefault()}, rateLimitDelay: {rateLimitDelay.FirstOrDefault()}," + - $"rateLimitLimit: {rateLimitLimit.FirstOrDefault()}, rateLimitRemaining: {rateLimitRemaining.FirstOrDefault()}, rateLimitReset: {rateLimitReset.FirstOrDefault()}"; - - _telemetryClient.TrackTrace(traceMessage); - } + var connection = await CreateVssConnection(); + var buildClient = connection.GetClient(); + var artifact = await buildClient.GetArtifactAsync(project, int.Parse(buildId), artifactName); + return artifact?.Resource?.DownloadUrl; + } - int count = 0; - int[] waitTimes = new int[] { 0, 1, 2, 4, 8, 16, 32, 64, 128, 256 }; - while ((downloadResp.StatusCode == HttpStatusCode.TooManyRequests || downloadResp.StatusCode == HttpStatusCode.BadRequest) && count < waitTimes.Length) - { - _telemetryClient.TrackTrace($"Download request from devops artifact is either throttled or flaky, waiting {waitTimes[count]} seconds before retrying, Retry count: {count}"); - await Task.Delay(TimeSpan.FromSeconds(waitTimes[count])); - downloadResp = await _devopsClient.GetAsync(request); - count++; - } - return downloadResp; + private async Task CreateVssConnection() + { + var accessToken = await getAccessToken(); + var token = new VssAadToken("Bearer", accessToken); + return new VssConnection(new Uri("https://dev.azure.com/azure-sdk/"), new VssAadCredential(token)); } - private async Task GetDownloadArtifactUrl(string repoName, string buildId, string artifactName, string project) + private async Task getAccessToken() { - var artifactGetReq = GetArtifactRestAPIForRepo(repoName).Replace("{buildId}", buildId).Replace("{artifactName}", artifactName).Replace("{project}", project); - var response = await GetFromDevopsAsync(artifactGetReq); - response.EnsureSuccessStatusCode(); - var buildResource = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - if (buildResource == null) - { - return null; - } - return buildResource.RootElement.GetProperty("resource").GetProperty("downloadUrl").GetString(); + // APIView deployed instances uses managed identity to authenticate requests to Azure DevOps. + // For local testing, VS will use developer credentials to create token + var credential = new DefaultAzureCredential(); + var tokenRequestContext = new TokenRequestContext(VssAadSettings.DefaultScopes); + var token = await credential.GetTokenAsync(tokenRequestContext, CancellationToken.None); + return token.Token; } - private string GetArtifactRestAPIForRepo(string repoName) + private async Task GetFromDevopsAsync(string request) { - var downloadArtifactRestApi = _configuration["download-artifact-rest-api-for-" + repoName]; - if (downloadArtifactRestApi == null) + var httpClient = new HttpClient(); + var maxRetryAttempts = 10; + var pauseBetweenFailures = TimeSpan.FromSeconds(2); + + var retryPolicy = Policy + .Handle() + .WaitAndRetryAsync(maxRetryAttempts, i => pauseBetweenFailures); + + HttpResponseMessage downloadResp = null; + await retryPolicy.ExecuteAsync(async () => { - downloadArtifactRestApi = _configuration["download-artifact-rest-api"]; - } - return downloadArtifactRestApi; + downloadResp = await httpClient.GetAsync(request); + }); + return downloadResp; } public async Task RunPipeline(string pipelineName, string reviewDetails, string originalStorageUrl) { //Create dictionary of all required parametes to run tools - generate--apireview pipeline in azure devops var reviewDetailsDict = new Dictionary { { "Reviews", reviewDetails }, { "APIViewUrl", _hostUrl }, { "StorageContainerUrl", originalStorageUrl } }; - var devOpsCreds = new VssBasicCredential("nobody", _configuration["Azure-Devops-PAT"]); - var devOpsConnection = new VssConnection(new Uri($"https://dev.azure.com/azure-sdk/"), devOpsCreds); + var devOpsConnection = await CreateVssConnection(); string projectName = _configuration["Azure-Devops-internal-project"] ?? "internal"; BuildHttpClient buildClient = await devOpsConnection.GetClientAsync(); @@ -132,29 +108,29 @@ public async Task RunPipeline(string pipelineName, string reviewDetails, string string envName = _configuration["apiview-deployment-environment"]; string updatedPipelineName = string.IsNullOrEmpty(envName) ? pipelineName : $"{pipelineName}-{envName}"; int definitionId = await GetPipelineId(updatedPipelineName, buildClient, projectName); - if (definitionId == 0) - { - throw new Exception(string.Format("Azure Devops pipeline is not found with name {0}. Please recheck and ensure pipeline exists with this name", updatedPipelineName)); + if (definitionId == 0) + { + throw new Exception(string.Format("Azure Devops pipeline is not found with name {0}. Please recheck and ensure pipeline exists with this name", updatedPipelineName)); } - var definition = await buildClient.GetDefinitionAsync(projectName, definitionId); + var definition = await buildClient.GetDefinitionAsync(projectName, definitionId); var project = await projectClient.GetProject(projectName); - await buildClient.QueueBuildAsync(new Build() - { - Definition = definition, - Project = project, - Parameters = JsonConvert.SerializeObject(reviewDetailsDict) + await buildClient.QueueBuildAsync(new Build() + { + Definition = definition, + Project = project, + Parameters = JsonConvert.SerializeObject(reviewDetailsDict) }); } - private async Task GetPipelineId(string pipelineName, BuildHttpClient client, string projectName) - { - var pipelines = await client.GetFullDefinitionsAsync2(project: projectName); - if (pipelines != null) - { - return pipelines.Single(p => p.Name == pipelineName).Id; - } - return 0; + private async Task GetPipelineId(string pipelineName, BuildHttpClient client, string projectName) + { + var pipelines = await client.GetFullDefinitionsAsync2(project: projectName); + if (pipelines != null) + { + return pipelines.Single(p => p.Name == pipelineName).Id; + } + return 0; } } } From 099f89b2c25228d7c6a5840c9c3f7e64e38d7241 Mon Sep 17 00:00:00 2001 From: Praveen Kuttappan Date: Sat, 13 Apr 2024 02:01:38 -0400 Subject: [PATCH 2/3] Update default devops project name to internal to download artifact --- .../APIViewWeb/Controllers/PullRequestController.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/dotnet/APIView/APIViewWeb/Controllers/PullRequestController.cs b/src/dotnet/APIView/APIViewWeb/Controllers/PullRequestController.cs index a52883fb139..0b5d9dea013 100644 --- a/src/dotnet/APIView/APIViewWeb/Controllers/PullRequestController.cs +++ b/src/dotnet/APIView/APIViewWeb/Controllers/PullRequestController.cs @@ -63,7 +63,8 @@ public async Task DetectApiChanges( string codeFile = null, string baselineCodeFile = null, bool commentOnPR = true, - string language = null) + string language = null, + string project = "internal") { if (!ValidateInputParams()) { @@ -80,7 +81,7 @@ public async Task DetectApiChanges( repoName: repoName, packageName: packageName, prNumber: pullRequestNumber, hostName: this.Request.Host.ToUriComponent(), codeFileName: codeFile, baselineCodeFileName: baselineCodeFile, - commentOnPR: commentOnPR, language: language); + commentOnPR: commentOnPR, language: language, project: project); return !string.IsNullOrEmpty(reviewUrl) ? StatusCode(statusCode: StatusCodes.Status201Created, reviewUrl) : StatusCode(statusCode: StatusCodes.Status208AlreadyReported); } @@ -102,7 +103,7 @@ private async Task DetectAPIChanges(string buildId, string baselineCodeFileName = null, bool commentOnPR = true, string language = null, - string project = "public") + string project = "internal") { language = LanguageServiceHelpers.MapLanguageAlias(language: language); var requestTelemetry = new RequestTelemetry { Name = "Detecting API changes for PR: " + prNumber }; From b5af2b753a11b3afc91a94e7e0b2c8d485ef2888 Mon Sep 17 00:00:00 2001 From: Praveen Kuttappan Date: Mon, 15 Apr 2024 19:21:24 -0400 Subject: [PATCH 3/3] use identity token to download content --- .../APIViewWeb/Repositories/DevopsArtifactRepository.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/dotnet/APIView/APIViewWeb/Repositories/DevopsArtifactRepository.cs b/src/dotnet/APIView/APIViewWeb/Repositories/DevopsArtifactRepository.cs index 3d8d8c94bc4..012c1a90091 100644 --- a/src/dotnet/APIView/APIViewWeb/Repositories/DevopsArtifactRepository.cs +++ b/src/dotnet/APIView/APIViewWeb/Repositories/DevopsArtifactRepository.cs @@ -13,6 +13,7 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; @@ -80,7 +81,11 @@ private async Task getAccessToken() private async Task GetFromDevopsAsync(string request) { - var httpClient = new HttpClient(); + var httpClient = new HttpClient(); + var accessToken = await getAccessToken(); + httpClient.DefaultRequestHeaders.Accept.Clear(); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); var maxRetryAttempts = 10; var pauseBetweenFailures = TimeSpan.FromSeconds(2);